/**
* Copyright (c) Codice Foundation
* <p>
* This is free software: you can redistribute it and/or modify it under the terms of the GNU Lesser
* General Public License as published by the Free Software Foundation, either version 3 of the
* License, or any later version.
* <p>
* This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
* even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details. A copy of the GNU Lesser General Public License
* is distributed along with this program and can be found at
* <http://www.gnu.org/licenses/lgpl.html>.
*/
package ddf.catalog.history;
import static ddf.catalog.core.versioning.MetacardVersion.SKIP_VERSIONING;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.Hashtable;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.Callable;
import java.util.concurrent.TimeUnit;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.stream.Collectors;
import javax.annotation.Nullable;
import org.apache.commons.lang3.StringUtils;
import org.codice.ddf.security.common.Security;
import org.opengis.filter.Filter;
import org.osgi.framework.Bundle;
import org.osgi.framework.BundleContext;
import org.osgi.framework.FrameworkUtil;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.google.common.collect.Lists;
import com.google.common.io.ByteSource;
import ddf.catalog.content.StorageException;
import ddf.catalog.content.StorageProvider;
import ddf.catalog.content.data.ContentItem;
import ddf.catalog.content.data.impl.ContentItemImpl;
import ddf.catalog.content.operation.CreateStorageResponse;
import ddf.catalog.content.operation.ReadStorageRequest;
import ddf.catalog.content.operation.ReadStorageResponse;
import ddf.catalog.content.operation.StorageRequest;
import ddf.catalog.content.operation.UpdateStorageRequest;
import ddf.catalog.content.operation.UpdateStorageResponse;
import ddf.catalog.content.operation.impl.CreateStorageRequestImpl;
import ddf.catalog.content.operation.impl.ReadStorageRequestImpl;
import ddf.catalog.core.versioning.MetacardVersion.Action;
import ddf.catalog.core.versioning.impl.DeletedMetacardImpl;
import ddf.catalog.core.versioning.impl.MetacardVersionImpl;
import ddf.catalog.data.Metacard;
import ddf.catalog.data.MetacardType;
import ddf.catalog.data.Result;
import ddf.catalog.data.impl.AttributeImpl;
import ddf.catalog.filter.FilterBuilder;
import ddf.catalog.operation.CreateResponse;
import ddf.catalog.operation.DeleteResponse;
import ddf.catalog.operation.Operation;
import ddf.catalog.operation.SourceResponse;
import ddf.catalog.operation.Update;
import ddf.catalog.operation.UpdateResponse;
import ddf.catalog.operation.impl.CreateRequestImpl;
import ddf.catalog.operation.impl.QueryImpl;
import ddf.catalog.operation.impl.QueryRequestImpl;
import ddf.catalog.source.CatalogProvider;
import ddf.catalog.source.IngestException;
import ddf.catalog.source.SourceUnavailableException;
import ddf.catalog.source.UnsupportedQueryException;
import ddf.security.SecurityConstants;
import ddf.security.Subject;
import ddf.security.SubjectUtils;
/**
* Class utilizing {@link StorageProvider} and {@link CatalogProvider} to version
* {@link Metacard}s and associated {@link ContentItem}s.
*/
public class Historian {
private static final Logger LOGGER = LoggerFactory.getLogger(Historian.class);
private final Predicate<Metacard> isNotVersionNorDeleted =
((Predicate<Metacard>) MetacardVersionImpl::isVersion).or(DeletedMetacardImpl::isDeleted)
.negate();
private boolean historyEnabled = true;
private List<StorageProvider> storageProviders;
private List<CatalogProvider> catalogProviders;
private List<MetacardType> metacardTypes;
private FilterBuilder filterBuilder;
private Security security;
public void init() {
Bundle bundle = FrameworkUtil.getBundle(Historian.class);
BundleContext context = bundle == null ? null : bundle.getBundleContext();
if (bundle == null || context == null) {
LOGGER.error("Could not get bundle to register history metacard types!");
} else {
DynamicMultiMetacardType versionType =
new DynamicMultiMetacardType(MetacardVersionImpl.PREFIX,
metacardTypes,
MetacardVersionImpl.getMetacardVersionType());
DynamicMultiMetacardType deleteType =
new DynamicMultiMetacardType(DeletedMetacardImpl.PREFIX,
metacardTypes,
DeletedMetacardImpl.getDeletedMetacardType());
context.registerService(MetacardType.class, versionType, new Hashtable<>());
context.registerService(MetacardType.class, deleteType, new Hashtable<>());
}
}
/**
* Versions metacards being updated based off of the {@link Update#getOldMetacard} method on
* {@link UpdateResponse}
*
* @param updateResponse Versioned metacards created from any old metacards
* @return The original UpdateResponse
* @throws SourceUnavailableException
* @throws IngestException
*/
public UpdateResponse version(UpdateResponse updateResponse)
throws SourceUnavailableException, IngestException {
if (doSkip(updateResponse)) {
return updateResponse;
}
setSkipFlag(updateResponse);
List<Metacard> inputMetacards = updateResponse.getUpdatedMetacards()
.stream()
.map(Update::getOldMetacard)
.filter(isNotVersionNorDeleted)
.collect(Collectors.toList());
final Map<String, Metacard> versionedMetacards = getVersionMetacards(inputMetacards,
(id) -> Action.VERSIONED,
(Subject) updateResponse.getRequest()
.getProperties()
.get(SecurityConstants.SECURITY_SUBJECT));
CreateResponse response = storeVersionMetacards(versionedMetacards);
return updateResponse;
}
/**
* Versions updated {@link Metacard}s and {@link ContentItem}s.
*
* @param streamUpdateRequest Needed to pass {@link ddf.catalog.core.versioning.MetacardVersion#SKIP_VERSIONING}
* flag into downstream update
* @param updateStorageResponse Versions this response's updated items
* @return the update response originally passed in
* @throws UnsupportedQueryException
* @throws SourceUnavailableException
* @throws IngestException
*/
public UpdateStorageResponse version(UpdateStorageRequest streamUpdateRequest,
UpdateStorageResponse updateStorageResponse, UpdateResponse updateResponse)
throws UnsupportedQueryException, SourceUnavailableException, IngestException {
if (doSkip(updateStorageResponse)) {
return updateStorageResponse;
}
setSkipFlag(streamUpdateRequest);
setSkipFlag(updateStorageResponse);
List<Metacard> updatedMetacards = updateStorageResponse.getUpdatedContentItems()
.stream()
.filter(ci -> StringUtils.isBlank(ci.getQualifier()))
.map(ContentItem::getMetacard)
.filter(Objects::nonNull)
.filter(isNotVersionNorDeleted)
.collect(Collectors.toList());
Map<String, Metacard> originalMetacards = query(forIds(updatedMetacards.stream()
.map(Metacard::getId)
.collect(Collectors.toList())));
Collection<ReadStorageRequest> ids = getReadStorageRequests(updatedMetacards);
Map<String, List<ContentItem>> content = getContent(ids);
Function<String, Action> getAction =
(id) -> content.containsKey(id) ? Action.VERSIONED_CONTENT : Action.VERSIONED;
Map<String, Metacard> versionMetacards = getVersionMetacards(originalMetacards.values(),
getAction,
(Subject) updateResponse.getProperties()
.get(SecurityConstants.SECURITY_SUBJECT));
CreateStorageResponse createStorageResponse = versionContentItems(content,
versionMetacards);
if (createStorageResponse == null) {
LOGGER.debug("Could not version content items.");
return updateStorageResponse;
}
setResourceUriForContent(/*mutable*/ versionMetacards, createStorageResponse);
CreateResponse createResponse = storeVersionMetacards(versionMetacards);
return updateStorageResponse;
}
/**
* Versions deleted {@link Metacard}s.
*
* @param deleteResponse Versions this responses deleted metacards
*/
public DeleteResponse version(DeleteResponse deleteResponse)
throws SourceUnavailableException, IngestException {
if (doSkip(deleteResponse)) {
return deleteResponse;
}
setSkipFlag(deleteResponse);
List<Metacard> deletedMetacards = deleteResponse.getDeletedMetacards()
.stream()
.filter(isNotVersionNorDeleted)
.collect(Collectors.toList());
// [ContentItem.getId: content items]
Map<String, List<ContentItem>> contentItems = getContent(getReadStorageRequests(
deletedMetacards));
Function<String, Action> getAction =
(id) -> contentItems.containsKey(id) ? Action.DELETED_CONTENT : Action.DELETED;
// [MetacardVersion.VERSION_OF_ID: versioned metacard]
Map<String, Metacard> versionedMap = getVersionMetacards(deletedMetacards,
getAction,
(Subject) deleteResponse.getRequest()
.getProperties()
.get(SecurityConstants.SECURITY_SUBJECT));
CreateStorageResponse createStorageResponse = versionContentItems(contentItems,
versionedMap);
if (createStorageResponse != null) {
setResourceUriForContent(/*Mutable*/ versionedMap, createStorageResponse);
}
CreateResponse createResponse =
executeAsSystem(() -> catalogProvider().create(new CreateRequestImpl(new ArrayList<>(
versionedMap.values()))));
String emailAddress = SubjectUtils.getEmailAddress((Subject) deleteResponse.getProperties()
.get(SecurityConstants.SECURITY_SUBJECT));
List<Metacard> deletionMetacards = versionedMap.entrySet()
.stream()
.map(s -> new DeletedMetacardImpl(s.getKey(),
emailAddress,
s.getValue()
.getId(),
MetacardVersionImpl.toMetacard(s.getValue(), metacardTypes)))
.collect(Collectors.toList());
CreateResponse deletionMetacardsCreateResponse =
executeAsSystem(() -> catalogProvider().create(new CreateRequestImpl(
deletionMetacards,
new HashMap<>())));
return deleteResponse;
}
public boolean isHistoryEnabled() {
return historyEnabled;
}
public void setHistoryEnabled(boolean historyEnabled) {
this.historyEnabled = historyEnabled;
}
public List<StorageProvider> getStorageProviders() {
return storageProviders;
}
public void setStorageProviders(List<StorageProvider> storageProviders) {
this.storageProviders = storageProviders;
}
public List<CatalogProvider> getCatalogProviders() {
return catalogProviders;
}
public void setCatalogProviders(List<CatalogProvider> catalogProviders) {
this.catalogProviders = catalogProviders;
}
public void setFilterBuilder(FilterBuilder filterBuilder) {
this.filterBuilder = filterBuilder;
}
public void setSkipFlag(@Nullable Operation op) {
Optional.ofNullable(op)
.map(Operation::getProperties)
.ifPresent(p -> p.put(SKIP_VERSIONING, true));
}
private List<String> fromStorageRequests(Collection<ReadStorageRequest> requests) {
return requests.stream()
.map(StorageRequest::getId)
.collect(Collectors.toList());
}
private Map<String, Metacard> query(Filter filter) throws UnsupportedQueryException {
SourceResponse response = catalogProvider().query(new QueryRequestImpl(new QueryImpl(filter,
1,
250,
null,
false,
TimeUnit.SECONDS.toMillis(10))));
return response.getResults()
.stream()
.map(Result::getMetacard)
.filter(Objects::nonNull)
.collect(Collectors.toMap(Metacard::getId, Function.identity()));
}
private Filter forIds(List<String> ids) {
List<Filter> idFilters = ids.stream()
.map(id -> filterBuilder.attribute(Metacard.ID)
.is()
.equalTo()
.text(id))
.collect(Collectors.toList());
return filterBuilder.anyOf(idFilters);
}
/*
* Assumptions: The ContentItem's <code>getId</code> method returns an ID that corresponds
* to the metacards ID.
*/
private Map<String, List<ContentItem>> getContent(Collection<ReadStorageRequest> ids) {
return ids.stream()
.map(this::getStorageItem)
.filter(Objects::nonNull)
.map(ReadStorageResponse::getContentItem)
.filter(Objects::nonNull)
.collect(Collectors.toMap(ContentItem::getId, Lists::newArrayList, (l, r) -> {
l.addAll(r);
return l;
}));
}
private List<ReadStorageRequest> getReadStorageRequests(List<Metacard> metacards) {
return metacards.stream()
.filter(m -> m.getResourceURI() != null)
.filter(m -> ContentItem.CONTENT_SCHEME.equals(m.getResourceURI()
.getScheme()))
.map(m -> new ReadStorageRequestImpl(m.getResourceURI(),
m.getId(),
new HashMap<>()))
.collect(Collectors.toList());
}
private ReadStorageResponse getStorageItem(ReadStorageRequest r) {
try {
return storageProvider().read(r);
} catch (StorageException e) {
LOGGER.debug("could not get storage item for metacard (id: {})(uri: {})",
r.getId(),
r.getResourceUri(),
e);
}
return null;
}
/* Map< Metacard ID, content item> */
private Map<String, List<ContentItem>> getContentItems(DeleteResponse deleteResponse) {
return getContent(getReadStorageRequests(deleteResponse.getDeletedMetacards()));
}
private CreateStorageResponse versionContentItems(Map<String, List<ContentItem>> items,
Map<String, Metacard> versionedMetacards)
throws SourceUnavailableException, IngestException {
List<ContentItem> contentItems = items.entrySet()
.stream()
.map(e -> getVersionedContentItems(e.getValue(), versionedMetacards))
.flatMap(Collection::stream)
.collect(Collectors.toList());
if (contentItems.isEmpty()) {
LOGGER.debug("No content items to version");
return null;
}
CreateStorageResponse createStorageResponse =
executeAsSystem(() -> storageProvider().create(new CreateStorageRequestImpl(
contentItems,
new HashMap<>())));
tryCommitStorage(createStorageResponse);
return createStorageResponse;
}
private void tryCommitStorage(CreateStorageResponse createStorageResponse)
throws IngestException {
try {
storageProvider().commit(createStorageResponse.getStorageRequest());
} catch (StorageException e) {
try {
storageProvider().rollback(createStorageResponse.getStorageRequest());
} catch (StorageException e1) {
LOGGER.debug("Could not rollback storage request", e1);
}
LOGGER.debug("Could not copy and store the previous resource", e);
throw new IngestException("Error Updating Metacard");
}
}
private List<ContentItemImpl> getVersionedContentItems(List<ContentItem> entry,
Map<String, Metacard> versionedMetacards) {
return entry.stream()
.map(content -> createContentItem(content, versionedMetacards))
.collect(Collectors.toList());
}
private ContentItemImpl createContentItem(ContentItem content,
Map<String, Metacard> versionedMetacards) {
long size = 0;
try {
size = content.getSize();
} catch (IOException e) {
LOGGER.debug("Could not get size of file. (file: {}) (id: {})",
content.getFilename(),
content.getId(),
e);
}
return new ContentItemImpl(versionedMetacards.get(content.getId())
.getId(),
content.getQualifier(),
new WrappedByteSource(content),
content.getMimeTypeRawData(),
content.getFilename(),
size,
versionedMetacards.get(content.getId()));
}
/*Map<MetacardVersion.VERSION_OF_ID -> MetacardVersion>*/
private Map<String, Metacard> getVersionMetacards(Collection<Metacard> metacards,
Function<String, Action> action, Subject subject) {
return metacards.stream()
.filter(MetacardVersionImpl::isNotVersion)
.filter(DeletedMetacardImpl::isNotDeleted)
.map(metacard -> new MetacardVersionImpl(metacard,
action.apply(metacard.getId()),
subject))
.collect(Collectors.toMap(MetacardVersionImpl::getVersionOfId,
Function.identity()));
}
/**
* Caution should be used with this, as it elevates the permissions to the System user.
*
* @param func What to execute as the System
* @param <T> Generic return type of func
* @return result of the callable func
*/
private <T> T executeAsSystem(Callable<T> func) {
if (security == null) {
security = Security.getInstance();
}
Subject systemSubject = security.runAsAdmin(() -> security.getSystemSubject());
if (systemSubject == null) {
throw new RuntimeException("Could not get systemSubject to version metacards.");
}
return systemSubject.execute(func);
}
private boolean doSkip(@Nullable Operation op) {
return !historyEnabled || ((boolean) Optional.ofNullable(op)
.map(Operation::getProperties)
.orElse(Collections.emptyMap())
.getOrDefault(SKIP_VERSIONING, false));
}
private CreateResponse storeVersionMetacards(Map<String, Metacard> versionMetacards) {
return executeAsSystem(() -> catalogProvider().create(new CreateRequestImpl(new ArrayList<>(
versionMetacards.values()))));
}
private void setResourceUriForContent(/*mutable*/ Map<String, Metacard> versionMetacards,
CreateStorageResponse createStorageResponse) {
for (ContentItem contentItem : createStorageResponse.getCreatedContentItems()) {
Metacard metacard = versionMetacards.values()
.stream()
.filter(m -> contentItem.getId()
.equals(m.getId()))
.findFirst()
.orElse(null);
if (metacard == null) {
LOGGER.info(
"Could not find version metacard to set resource URI for (contentItem id: {})",
contentItem.getId());
continue;
}
metacard.setAttribute(new AttributeImpl(Metacard.RESOURCE_URI, contentItem.getUri()));
}
}
private StorageProvider storageProvider() {
return storageProviders.stream()
.findFirst()
.orElseThrow(() -> new IllegalStateException(
"Cannot version metacards without a storage provider"));
}
private CatalogProvider catalogProvider() {
return catalogProviders.stream()
.findFirst()
.orElseThrow(() -> new IllegalStateException(
"Cannot version metacards without a storage provider"));
}
public void setMetacardTypes(List<MetacardType> metacardTypes) {
this.metacardTypes = metacardTypes;
}
void setSecurity(Security security) {
this.security = security;
}
private static class WrappedByteSource extends ByteSource {
private ContentItem contentItem;
private WrappedByteSource(ContentItem contentItem) {
this.contentItem = contentItem;
}
@Override
public InputStream openStream() throws IOException {
return contentItem.getInputStream();
}
}
}