/** * Licensed to The Apereo Foundation under one or more contributor license * agreements. See the NOTICE file distributed with this work for additional * information regarding copyright ownership. * * * The Apereo Foundation licenses this file to you under the Educational * Community 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://opensource.org/licenses/ecl2.txt * * 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 org.opencastproject.search.impl; import static org.opencastproject.security.api.SecurityConstants.GLOBAL_ADMIN_ROLE; import org.opencastproject.job.api.AbstractJobProducer; import org.opencastproject.job.api.Job; import org.opencastproject.mediapackage.MediaPackage; import org.opencastproject.mediapackage.MediaPackageException; import org.opencastproject.mediapackage.MediaPackageParser; import org.opencastproject.mediapackage.MediaPackageSerializer; import org.opencastproject.metadata.api.StaticMetadataService; import org.opencastproject.metadata.mpeg7.Mpeg7CatalogService; import org.opencastproject.search.api.SearchException; import org.opencastproject.search.api.SearchQuery; import org.opencastproject.search.api.SearchResult; import org.opencastproject.search.api.SearchService; import org.opencastproject.search.impl.persistence.SearchServiceDatabase; import org.opencastproject.search.impl.persistence.SearchServiceDatabaseException; import org.opencastproject.search.impl.solr.SolrIndexManager; import org.opencastproject.search.impl.solr.SolrRequester; import org.opencastproject.security.api.AccessControlList; import org.opencastproject.security.api.AuthorizationService; import org.opencastproject.security.api.Organization; import org.opencastproject.security.api.OrganizationDirectoryService; import org.opencastproject.security.api.Permissions; import org.opencastproject.security.api.SecurityService; import org.opencastproject.security.api.UnauthorizedException; import org.opencastproject.security.api.User; import org.opencastproject.security.api.UserDirectoryService; import org.opencastproject.security.util.SecurityUtil; import org.opencastproject.series.api.SeriesService; import org.opencastproject.serviceregistry.api.ServiceRegistry; import org.opencastproject.serviceregistry.api.ServiceRegistryException; import org.opencastproject.solr.SolrServerFactory; import org.opencastproject.util.LoadUtil; import org.opencastproject.util.NotFoundException; import org.opencastproject.util.data.Tuple; import org.opencastproject.workspace.api.Workspace; import org.apache.commons.io.FileUtils; import org.apache.commons.io.FilenameUtils; import org.apache.commons.io.IOUtils; import org.apache.commons.lang3.StringUtils; import org.apache.solr.client.solrj.SolrServer; import org.apache.solr.client.solrj.SolrServerException; import org.osgi.framework.ServiceException; import org.osgi.service.cm.ConfigurationException; import org.osgi.service.cm.ManagedService; import org.osgi.service.component.ComponentContext; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.File; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; import java.net.MalformedURLException; import java.net.URL; import java.util.ArrayList; import java.util.Arrays; import java.util.Date; import java.util.Dictionary; import java.util.Iterator; import java.util.List; /** * A Solr-based {@link SearchService} implementation. */ public final class SearchServiceImpl extends AbstractJobProducer implements SearchService, ManagedService { /** Log facility */ private static final Logger logger = LoggerFactory.getLogger(SearchServiceImpl.class); /** Configuration key for a remote solr server */ public static final String CONFIG_SOLR_URL = "org.opencastproject.search.solr.url"; /** Configuration key for an embedded solr configuration and data directory */ public static final String CONFIG_SOLR_ROOT = "org.opencastproject.search.solr.dir"; /** The job type */ public static final String JOB_TYPE = "org.opencastproject.search"; /** The load introduced on the system by creating an add job */ public static final float DEFAULT_ADD_JOB_LOAD = 1.0f; /** The load introduced on the system by creating a delete job */ public static final float DEFAULT_DELETE_JOB_LOAD = 1.0f; /** The key to look for in the service configuration file to override the {@link DEFAULT_ADD_JOB_LOAD} */ public static final String ADD_JOB_LOAD_KEY = "job.load.add"; /** The key to look for in the service configuration file to override the {@link DEFAULT_DELETE_JOB_LOAD} */ public static final String DELETE_JOB_LOAD_KEY = "job.load.delete"; /** The load introduced on the system by creating an add job */ private float addJobLoad = DEFAULT_ADD_JOB_LOAD; /** The load introduced on the system by creating a delete job */ private float deleteJobLoad = DEFAULT_DELETE_JOB_LOAD; /** counter how often the index has already been tried to populate */ private int retriesToPopulateIndex = 0; /** List of available operations on jobs */ private enum Operation { Add, Delete }; /** Solr server */ private SolrServer solrServer; private SolrRequester solrRequester; private SolrIndexManager indexManager; private List<StaticMetadataService> mdServices = new ArrayList<StaticMetadataService>(); private Mpeg7CatalogService mpeg7CatalogService; private SeriesService seriesService; /** The local workspace */ private Workspace workspace; /** The security service */ private SecurityService securityService; /** The authorization service */ private AuthorizationService authorizationService; /** The service registry */ private ServiceRegistry serviceRegistry; /** Persistent storage */ private SearchServiceDatabase persistence; /** The user directory service */ protected UserDirectoryService userDirectoryService = null; /** The organization directory service */ protected OrganizationDirectoryService organizationDirectory = null; /** The optional Mediapackage serializer */ protected MediaPackageSerializer serializer = null; /** * Creates a new instance of the search service. */ public SearchServiceImpl() { super(JOB_TYPE); } /** * Return the solr index manager * * @return indexManager */ public SolrIndexManager getSolrIndexManager() { return indexManager; } /** * Service activator, called via declarative services configuration. If the solr server url is configured, we try to * connect to it. If not, the solr data directory with an embedded Solr server is used. * * @param cc * the component context */ @Override public void activate(final ComponentContext cc) throws IllegalStateException { super.activate(cc); final String solrServerUrlConfig = StringUtils.trimToNull(cc.getBundleContext().getProperty(CONFIG_SOLR_URL)); logger.info("Setting up solr server"); solrServer = new Object() { SolrServer create() { if (solrServerUrlConfig != null) { /* Use external SOLR server */ try { logger.info("Setting up solr server at {}", solrServerUrlConfig); URL solrServerUrl = new URL(solrServerUrlConfig); return setupSolr(solrServerUrl); } catch (MalformedURLException e) { throw connectError(solrServerUrlConfig, e); } } else { /* Set-up embedded SOLR */ String solrRoot = SolrServerFactory.getEmbeddedDir(cc, CONFIG_SOLR_ROOT, "search"); try { logger.debug("Setting up solr server at {}", solrRoot); return setupSolr(new File(solrRoot)); } catch (IOException e) { throw connectError(solrServerUrlConfig, e); } catch (SolrServerException e) { throw connectError(solrServerUrlConfig, e); } } } IllegalStateException connectError(String target, Exception e) { logger.error("Unable to connect to solr at {}: {}", target, e.getMessage()); return new IllegalStateException("Unable to connect to solr at " + target, e); } // CHECKSTYLE:OFF }.create(); // CHECKSTYLE:ON solrRequester = new SolrRequester(solrServer, securityService, serializer); indexManager = new SolrIndexManager(solrServer, workspace, mdServices, seriesService, mpeg7CatalogService, securityService); String systemUserName = cc.getBundleContext().getProperty(SecurityUtil.PROPERTY_KEY_SYS_USER); populateIndex(systemUserName); } /** * Service deactivator, called via declarative services configuration. */ public void deactivate() { SolrServerFactory.shutdown(solrServer); } /** * Prepares the embedded solr environment. * * @param solrRoot * the solr root directory */ static SolrServer setupSolr(File solrRoot) throws IOException, SolrServerException { logger.info("Setting up solr search index at {}", solrRoot); File solrConfigDir = new File(solrRoot, "conf"); // Create the config directory if (solrConfigDir.exists()) { logger.info("solr search index found at {}", solrConfigDir); } else { logger.info("solr config directory doesn't exist. Creating {}", solrConfigDir); FileUtils.forceMkdir(solrConfigDir); } // Make sure there is a configuration in place copyClasspathResourceToFile("/solr/conf/protwords.txt", solrConfigDir); copyClasspathResourceToFile("/solr/conf/schema.xml", solrConfigDir); copyClasspathResourceToFile("/solr/conf/scripts.conf", solrConfigDir); copyClasspathResourceToFile("/solr/conf/solrconfig.xml", solrConfigDir); copyClasspathResourceToFile("/solr/conf/stopwords.txt", solrConfigDir); copyClasspathResourceToFile("/solr/conf/synonyms.txt", solrConfigDir); // Test for the existence of a data directory File solrDataDir = new File(solrRoot, "data"); if (!solrDataDir.exists()) { FileUtils.forceMkdir(solrDataDir); } // Test for the existence of the index. Note that an empty index directory will prevent solr from // completing normal setup. File solrIndexDir = new File(solrDataDir, "index"); if (solrIndexDir.isDirectory() && solrIndexDir.list().length == 0) { FileUtils.deleteDirectory(solrIndexDir); } return SolrServerFactory.newEmbeddedInstance(solrRoot, solrDataDir); } /** * Prepares the embedded solr environment. * * @param url * the url of the remote solr server */ static SolrServer setupSolr(URL url) { logger.info("Connecting to solr search index at {}", url); return SolrServerFactory.newRemoteInstance(url); } // TODO: generalize this method static void copyClasspathResourceToFile(String classpath, File dir) { InputStream in = null; FileOutputStream fos = null; try { in = SearchServiceImpl.class.getResourceAsStream(classpath); File file = new File(dir, FilenameUtils.getName(classpath)); logger.debug("copying " + classpath + " to " + file); fos = new FileOutputStream(file); IOUtils.copy(in, fos); } catch (IOException e) { throw new RuntimeException("Error copying solr classpath resource to the filesystem", e); } finally { IOUtils.closeQuietly(in); IOUtils.closeQuietly(fos); } } /** * {@inheritDoc} * * @see org.opencastproject.search.api.SearchService#getByQuery(java.lang.String, int, int) */ public SearchResult getByQuery(String query, int limit, int offset) throws SearchException { try { logger.debug("Searching index using custom query '" + query + "'"); return solrRequester.getByQuery(query, limit, offset); } catch (SolrServerException e) { throw new SearchException(e); } } /** * {@inheritDoc} * * @see org.opencastproject.search.api.SearchService#add(org.opencastproject.mediapackage.MediaPackage) */ public Job add(MediaPackage mediaPackage) throws SearchException, MediaPackageException, IllegalArgumentException, UnauthorizedException, ServiceRegistryException { try { return serviceRegistry.createJob(JOB_TYPE, Operation.Add.toString(), Arrays.asList(MediaPackageParser.getAsXml(mediaPackage)), addJobLoad); } catch (ServiceRegistryException e) { throw new SearchException(e); } } /** * Immediately adds the mediapackage to the search index. * * @param mediaPackage * the media package * @throws SearchException * if the media package cannot be added to the search index * @throws MediaPackageException * if the mediapckage is invalid * @throws IllegalArgumentException * if the mediapackage is <code>null</code> * @throws UnauthorizedException * if the user does not have the rights to add the mediapackage */ public void addSynchronously(MediaPackage mediaPackage) throws SearchException, MediaPackageException, IllegalArgumentException, UnauthorizedException { User currentUser = securityService.getUser(); String orgAdminRole = securityService.getOrganization().getAdminRole(); if (!currentUser.hasRole(orgAdminRole) && !currentUser.hasRole(GLOBAL_ADMIN_ROLE) && !authorizationService.hasPermission(mediaPackage, Permissions.Action.WRITE.toString())) { throw new UnauthorizedException(currentUser, Permissions.Action.WRITE.toString()); } if (mediaPackage == null) { throw new IllegalArgumentException("Unable to add a null mediapackage"); } logger.debug("Attempting to add mediapackage {} to search index", mediaPackage.getIdentifier()); AccessControlList acl = authorizationService.getActiveAcl(mediaPackage).getA(); Date now = new Date(); try { if (indexManager.add(mediaPackage, acl, now)) { logger.info("Added mediapackage `{}` to the search index, using ACL `{}`", mediaPackage, acl); } else { logger.warn("Failed to add mediapackage {} to the search index", mediaPackage.getIdentifier()); } } catch (SolrServerException e) { throw new SearchException(e); } try { persistence.storeMediaPackage(mediaPackage, acl, now); } catch (SearchServiceDatabaseException e) { logger.error("Could not store media package to search database {}: {}", mediaPackage.getIdentifier(), e); throw new SearchException(e); } } /** * {@inheritDoc} * * @see org.opencastproject.search.api.SearchService#delete(java.lang.String) */ public Job delete(String mediaPackageId) throws SearchException, UnauthorizedException, NotFoundException { try { return serviceRegistry.createJob(JOB_TYPE, Operation.Delete.toString(), Arrays.asList(mediaPackageId), deleteJobLoad); } catch (ServiceRegistryException e) { throw new SearchException(e); } } /** * Immediately removes the given mediapackage from the search service. * * @param mediaPackageId * the mediapackage * @return <code>true</code> if the mediapackage was deleted * @throws SearchException * if deletion failed * @throws UnauthorizedException * if the user did not have access to the media package * @throws NotFoundException * if the mediapackage did not exist */ public boolean deleteSynchronously(String mediaPackageId) throws SearchException, UnauthorizedException, NotFoundException { SearchResult result; try { result = solrRequester.getForWrite(new SearchQuery().withId(mediaPackageId)); if (result.getItems().length == 0) { logger.warn( "Can not delete mediapackage {}, which is not available for the current user to delete from the search index.", mediaPackageId); return false; } logger.info("Removing mediapackage {} from search index", mediaPackageId); Date now = new Date(); try { persistence.deleteMediaPackage(mediaPackageId, now); logger.info("Removed mediapackage {} from search persistence", mediaPackageId); } catch (NotFoundException e) { // even if mp not found in persistence, it might still exist in search index. logger.info("Could not find mediapackage with id {} in persistence, but will try remove it from index, anyway.", mediaPackageId); } catch (SearchServiceDatabaseException e) { logger.error("Could not delete media package with id {} from persistence storage", mediaPackageId); throw new SearchException(e); } return indexManager.delete(mediaPackageId, now); } catch (SolrServerException e) { logger.info("Could not delete media package with id {} from search index", mediaPackageId); throw new SearchException(e); } } /** * Clears the complete solr index. * * @throws SearchException * if clearing the index fails */ public void clear() throws SearchException { try { logger.info("Clearing the search index"); indexManager.clear(); } catch (SolrServerException e) { throw new SearchException(e); } } /** * {@inheritDoc} * * @see org.opencastproject.search.api.SearchService#getByQuery(org.opencastproject.search.api.SearchQuery) */ public SearchResult getByQuery(SearchQuery q) throws SearchException { try { logger.debug("Searching index using query object '" + q + "'"); return solrRequester.getForRead(q); } catch (SolrServerException e) { throw new SearchException(e); } } /** * {@inheritDoc} * * @see org.opencastproject.search.api.SearchService#getForAdministrativeRead(org.opencastproject.search.api.SearchQuery) */ @Override public SearchResult getForAdministrativeRead(SearchQuery q) throws SearchException, UnauthorizedException { User user = securityService.getUser(); if (!user.hasRole(GLOBAL_ADMIN_ROLE) && !user.hasRole(user.getOrganization().getAdminRole())) throw new UnauthorizedException(user, getClass().getName() + ".getForAdministrativeRead"); try { return solrRequester.getForAdministrativeRead(q); } catch (SolrServerException e) { throw new SearchException(e); } } protected void populateIndex(String systemUserName) { long instancesInSolr = 0L; try { instancesInSolr = indexManager.count(); } catch (Exception e) { throw new IllegalStateException(e); } if (instancesInSolr > 0) { logger.debug("Search index found"); return; } if (instancesInSolr == 0L) { logger.info("No search index found"); logger.info("Starting population of search index from database"); Iterator<Tuple<MediaPackage, String>> mediaPackages; try { mediaPackages = persistence.getAllMediaPackages(); } catch (SearchServiceDatabaseException e) { logger.error("Unable to load the search entries: {}", e.getMessage()); throw new ServiceException(e.getMessage()); } int errors = 0; while (mediaPackages.hasNext()) { try { Tuple<MediaPackage, String> mediaPackage = mediaPackages.next(); String mediaPackageId = mediaPackage.getA().getIdentifier().toString(); Organization organization = organizationDirectory.getOrganization(mediaPackage.getB()); securityService.setOrganization(organization); securityService.setUser(SecurityUtil.createSystemUser(systemUserName, organization)); AccessControlList acl = persistence.getAccessControlList(mediaPackageId); Date modificationDate = persistence.getModificationDate(mediaPackageId); Date deletionDate = persistence.getDeletionDate(mediaPackageId); indexManager.add(mediaPackage.getA(), acl, deletionDate, modificationDate); } catch (Exception e) { logger.error("Unable to index search instances: {}", e); if (retryToPopulateIndex(systemUserName)) { logger.warn("Trying to re-index search index later. Aborting for now."); return; } errors++; } finally { securityService.setOrganization(null); securityService.setUser(null); } } if (errors > 0) logger.error("Skipped {} erroneous search entries while populating the search index", errors); logger.info("Finished populating search index"); } } private boolean retryToPopulateIndex(final String systemUserName) { if (retriesToPopulateIndex > 0) { return false; } long instancesInSolr = 0L; try { instancesInSolr = indexManager.count(); } catch (Exception e) { throw new IllegalStateException(e); } if (instancesInSolr > 0) { logger.debug("Search index found, other files could be indexed. No retry needed."); return false; } retriesToPopulateIndex++; new Thread() { public void run() { try { Thread.sleep(30000); } catch (InterruptedException ex) { } populateIndex(systemUserName); } }.start(); return true; } /** * @see org.opencastproject.job.api.AbstractJobProducer#process(org.opencastproject.job.api.Job) */ @Override protected String process(Job job) throws Exception { Operation op = null; String operation = job.getOperation(); List<String> arguments = job.getArguments(); try { op = Operation.valueOf(operation); switch (op) { case Add: MediaPackage mediaPackage = MediaPackageParser.getFromXml(arguments.get(0)); addSynchronously(mediaPackage); return null; case Delete: String mediapackageId = arguments.get(0); boolean deleted = deleteSynchronously(mediapackageId); return Boolean.toString(deleted); default: throw new IllegalStateException("Don't know how to handle operation '" + operation + "'"); } } catch (IllegalArgumentException e) { throw new ServiceRegistryException("This service can't handle operations of type '" + op + "'", e); } catch (IndexOutOfBoundsException e) { throw new ServiceRegistryException("This argument list for operation '" + op + "' does not meet expectations", e); } catch (Exception e) { throw new ServiceRegistryException("Error handling operation '" + op + "'", e); } } /** For testing purposes only! */ void testSetup(SolrServer server, SolrRequester requester, SolrIndexManager manager) { this.solrServer = server; this.solrRequester = requester; this.indexManager = manager; } /** Dynamic reference. */ public void setStaticMetadataService(StaticMetadataService mdService) { this.mdServices.add(mdService); if (indexManager != null) indexManager.setStaticMetadataServices(mdServices); } public void unsetStaticMetadataService(StaticMetadataService mdService) { this.mdServices.remove(mdService); if (indexManager != null) indexManager.setStaticMetadataServices(mdServices); } public void setMpeg7CatalogService(Mpeg7CatalogService mpeg7CatalogService) { this.mpeg7CatalogService = mpeg7CatalogService; } public void setPersistence(SearchServiceDatabase persistence) { this.persistence = persistence; } public void setSeriesService(SeriesService seriesService) { this.seriesService = seriesService; } public void setWorkspace(Workspace workspace) { this.workspace = workspace; } public void setAuthorizationService(AuthorizationService authorizationService) { this.authorizationService = authorizationService; } public void setServiceRegistry(ServiceRegistry serviceRegistry) { this.serviceRegistry = serviceRegistry; } /** * Callback for setting the security service. * * @param securityService * the securityService to set */ public void setSecurityService(SecurityService securityService) { this.securityService = securityService; } /** * Callback for setting the user directory service. * * @param userDirectoryService * the userDirectoryService to set */ public void setUserDirectoryService(UserDirectoryService userDirectoryService) { this.userDirectoryService = userDirectoryService; } /** * Sets a reference to the organization directory service. * * @param organizationDirectory * the organization directory */ public void setOrganizationDirectoryService(OrganizationDirectoryService organizationDirectory) { this.organizationDirectory = organizationDirectory; } /** * @see org.opencastproject.job.api.AbstractJobProducer#getOrganizationDirectoryService() */ @Override protected OrganizationDirectoryService getOrganizationDirectoryService() { return organizationDirectory; } /** * @see org.opencastproject.job.api.AbstractJobProducer#getSecurityService() */ @Override protected SecurityService getSecurityService() { return securityService; } /** * @see org.opencastproject.job.api.AbstractJobProducer#getServiceRegistry() */ @Override protected ServiceRegistry getServiceRegistry() { return serviceRegistry; } /** * @see org.opencastproject.job.api.AbstractJobProducer#getUserDirectoryService() */ @Override protected UserDirectoryService getUserDirectoryService() { return userDirectoryService; } /** * Sets the optional MediaPackage serializer. * * @param serializer * the serializer */ protected void setMediaPackageSerializer(MediaPackageSerializer serializer) { this.serializer = serializer; if (solrRequester != null) solrRequester.setMediaPackageSerializer(serializer); } @Override public void updated(@SuppressWarnings("rawtypes") Dictionary properties) throws ConfigurationException { addJobLoad = LoadUtil.getConfiguredLoadValue(properties, ADD_JOB_LOAD_KEY, DEFAULT_ADD_JOB_LOAD, serviceRegistry); deleteJobLoad = LoadUtil.getConfiguredLoadValue(properties, DELETE_JOB_LOAD_KEY, DEFAULT_DELETE_JOB_LOAD, serviceRegistry); } }