/**
* 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 org.codice.ddf.commands.catalog;
import java.io.File;
import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
import java.nio.file.Paths;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.TimeZone;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.function.Supplier;
import java.util.stream.Collectors;
import org.apache.commons.io.FilenameUtils;
import org.apache.commons.lang.StringUtils;
import org.apache.karaf.shell.api.action.Command;
import org.apache.karaf.shell.api.action.Option;
import org.apache.karaf.shell.api.action.lifecycle.Reference;
import org.apache.karaf.shell.api.action.lifecycle.Service;
import org.codice.ddf.catalog.transformer.zip.JarSigner;
import org.codice.ddf.commands.catalog.export.ExportItem;
import org.codice.ddf.commands.catalog.export.IdAndUriMetacard;
import org.codice.ddf.commands.util.CatalogCommandRuntimeException;
import org.codice.ddf.commands.util.QueryResultIterable;
import org.fusesource.jansi.Ansi;
import org.geotools.filter.text.cql2.CQLException;
import org.opengis.filter.Filter;
import org.opengis.filter.sort.SortBy;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import ddf.catalog.content.StorageException;
import ddf.catalog.content.StorageProvider;
import ddf.catalog.content.data.ContentItem;
import ddf.catalog.content.operation.impl.DeleteStorageRequestImpl;
import ddf.catalog.core.versioning.DeletedMetacard;
import ddf.catalog.core.versioning.MetacardVersion;
import ddf.catalog.data.BinaryContent;
import ddf.catalog.data.Metacard;
import ddf.catalog.data.Result;
import ddf.catalog.operation.ResourceResponse;
import ddf.catalog.operation.impl.DeleteRequestImpl;
import ddf.catalog.operation.impl.QueryImpl;
import ddf.catalog.operation.impl.QueryRequestImpl;
import ddf.catalog.operation.impl.ResourceRequestByProductUri;
import ddf.catalog.resource.ResourceNotFoundException;
import ddf.catalog.resource.ResourceNotSupportedException;
import ddf.catalog.source.IngestException;
import ddf.catalog.transform.CatalogTransformerException;
import ddf.catalog.transform.MetacardTransformer;
import ddf.security.common.audit.SecurityLogger;
import net.lingala.zip4j.core.ZipFile;
import net.lingala.zip4j.exception.ZipException;
import net.lingala.zip4j.model.ZipParameters;
/**
* Exports Metacards, History, and their content into a zip file.
* <b> This code is experimental. While this interface is functional and tested, it may change or be
* removed in a future version of the library. </b>
*/
@Service
@Command(scope = CatalogCommands.NAMESPACE, name = "export", description = "Exports Metacards and history from the current Catalog")
public class ExportCommand extends CqlCommands {
private static final Logger LOGGER = LoggerFactory.getLogger(ExportCommand.class);
private static final SimpleDateFormat ISO_8601_DATE_FORMAT;
static {
ISO_8601_DATE_FORMAT = new SimpleDateFormat("yyyy-MM-dd'T'HH-mm-ss.SSS'Z'");
ISO_8601_DATE_FORMAT.setTimeZone(TimeZone.getTimeZone("UTC"));
}
private static final Supplier<String> FILE_NAMER =
() -> "export-" + ISO_8601_DATE_FORMAT.format(Date.from(Instant.now())) + ".zip";
private static final int PAGE_SIZE = 64;
private MetacardTransformer transformer;
private Filter revisionFilter;
private JarSigner jarSigner = new JarSigner();
@Reference
private StorageProvider storageProvider;
@Option(name = "--output", description = "Output file to export Metacards and contents into. Paths are absolute and must be in quotes. Will default to auto generated name inside of ddf.home", multiValued = false, required = false, aliases = {
"-o"})
String output = Paths.get(System.getProperty("ddf.home"), FILE_NAMER.get())
.toString();
@Option(name = "--delete", required = true, aliases = {
"-d"}, multiValued = false, description = "Delete Metacards and content after export. EG: --delete=true or --delete=false")
boolean delete = false;
@Option(name = "--archived", required = false, aliases = {"-a",
"archived"}, multiValued = false, description = "Equivalent to --cql \"\\\"metacard-tags\\\" like 'deleted'\"")
boolean archived = false;
@Option(name = "--force", required = false, aliases = {
"-f"}, multiValued = false, description = "Do not prompt")
boolean force = false;
@Option(name = "--skip-signature-verification", required = false, multiValued = false, description = "Produces the export zip but does NOT sign the resulting zip file. This file will not be able to be verified on import for integrity and security.")
boolean unsafe = false;
@Override
protected Object executeWithSubject() throws Exception {
Filter filter = getFilter();
transformer = getServiceByFilter(MetacardTransformer.class,
String.format("(%s=%s)",
"id",
DEFAULT_TRANSFORMER_ID)).orElseThrow(() -> new CatalogCommandRuntimeException(
"Could not get " + DEFAULT_TRANSFORMER_ID + " transformer"));
revisionFilter = initRevisionFilter();
final File outputFile = initOutputFile(output);
if (outputFile.exists()) {
printErrorMessage(String.format("File [%s] already exists!", outputFile.getPath()));
return null;
}
final File parentDirectory = outputFile.getParentFile();
if (parentDirectory == null || !parentDirectory.isDirectory()) {
printErrorMessage(String.format("Directory [%s] must exist.", output));
console.println("If the directory does indeed exist, try putting the path in quotes.");
return null;
}
String filename = FilenameUtils.getName(outputFile.getPath());
if (StringUtils.isBlank(filename) || !filename.endsWith(".zip")) {
console.println("Filename must end with '.zip' and not be blank");
return null;
}
if (delete && !force) {
console.println(
"This action will remove all exported metacards and content from the catalog. Are you sure you wish to continue? (y/N):");
String input = getUserInputModifiable().toString();
if (!input.matches("^[yY][eE]?[sS]?$")) {
console.println("ABORTED EXPORT.");
return null;
}
}
SecurityLogger.audit("Called catalog:export command with path : {}", output);
ZipFile zipFile = new ZipFile(outputFile);
console.println("Starting metacard export...");
Instant start = Instant.now();
List<ExportItem> exportedItems = doMetacardExport(zipFile, filter);
console.println("Metacards exported in: " + getFormattedDuration(start));
console.println("Number of metacards exported: " + exportedItems.size());
console.println();
SecurityLogger.audit("Ids of exported metacards and content:\n{}",
exportedItems.stream()
.map(ExportItem::getId)
.distinct()
.collect(Collectors.joining(", ", "[", "]")));
console.println("Starting content export...");
start = Instant.now();
List<ExportItem> exportedContentItems = doContentExport(zipFile, exportedItems);
console.println("Content exported in: " + getFormattedDuration(start));
console.println("Number of content exported: " + exportedContentItems.size());
console.println();
if (delete) {
doDelete(exportedItems, exportedContentItems);
}
if (!unsafe) {
SecurityLogger.audit("Signing exported data. file: [{}]",
zipFile.getFile()
.getName());
console.println("Signing zip file...");
start = Instant.now();
jarSigner.signJar(zipFile.getFile(),
System.getProperty("org.codice.ddf.system.hostname"),
System.getProperty("javax.net.ssl.keyStorePassword"),
System.getProperty("javax.net.ssl.keyStore"),
System.getProperty("javax.net.ssl.keyStorePassword"));
console.println("zip file signed in: " + getFormattedDuration(start));
}
console.println("Export complete.");
console.println("Exported to: " + zipFile.getFile()
.getCanonicalPath());
return null;
}
private File initOutputFile(String output) {
String resolvedOutput;
File initialOutputFile = new File(output);
if (initialOutputFile.isDirectory()) {
// If directory was specified, auto generate file name
resolvedOutput = Paths.get(initialOutputFile.getPath(), FILE_NAMER.get())
.toString();
} else {
resolvedOutput = output;
}
return new File(resolvedOutput);
}
private List<ExportItem> doMetacardExport(/*Mutable,IO*/ZipFile zipFile, Filter filter) {
Set<String> seenIds = new HashSet<>(1024);
List<ExportItem> exportedItems = new ArrayList<>();
for (Result result : new QueryResultIterable(catalogFramework,
(i) -> getQuery(filter, i, PAGE_SIZE),
PAGE_SIZE)) {
if (!seenIds.contains(result.getMetacard()
.getId())) {
writeToZip(zipFile, result);
exportedItems.add(new ExportItem(result.getMetacard()
.getId(),
getTag(result),
result.getMetacard()
.getResourceURI(),
getDerivedResources(result)));
seenIds.add(result.getMetacard()
.getId());
}
// Fetch and export all history for each exported item
for (Result revision : new QueryResultIterable(catalogFramework,
(i) -> getQuery(getHistoryFilter(result), i, PAGE_SIZE),
PAGE_SIZE)) {
if (seenIds.contains(revision.getMetacard()
.getId())) {
continue;
}
writeToZip(zipFile, revision);
exportedItems.add(new ExportItem(revision.getMetacard()
.getId(),
getTag(revision),
revision.getMetacard()
.getResourceURI(),
getDerivedResources(result)));
seenIds.add(revision.getMetacard()
.getId());
}
}
return exportedItems;
}
private List<String> getDerivedResources(Result result) {
if (result.getMetacard()
.getAttribute(Metacard.DERIVED_RESOURCE_URI) == null) {
return Collections.emptyList();
}
return result.getMetacard()
.getAttribute(Metacard.DERIVED_RESOURCE_URI)
.getValues()
.stream()
.filter(Objects::nonNull)
.map(String::valueOf)
.collect(Collectors.toList());
}
private List<ExportItem> doContentExport(/*Mutable,IO*/ZipFile zipFile,
List<ExportItem> exportedItems) throws ZipException {
List<ExportItem> contentItemsToExport = exportedItems.stream()
// Only things with a resource URI
.filter(ei -> ei.getResourceUri() != null)
// Only our content scheme
.filter(ei -> ei.getResourceUri()
.getScheme() != null)
.filter(ei -> ei.getResourceUri()
.getScheme()
.startsWith(ContentItem.CONTENT_SCHEME))
// Deleted Metacards have no content associated
.filter(ei -> !ei.getMetacardTag()
.equals("deleted"))
// for revision metacards, only those that have their own content
.filter(ei -> !ei.getMetacardTag()
.equals("revision") || ei.getResourceUri()
.getSchemeSpecificPart()
.equals(ei.getId()))
.filter(distinctByKey(ei -> ei.getResourceUri()
.getSchemeSpecificPart()))
.collect(Collectors.toList());
List<ExportItem> exportedContentItems = new ArrayList<>();
for (ExportItem contentItem : contentItemsToExport) {
ResourceResponse resource;
try {
resource = catalogFramework.getLocalResource(new ResourceRequestByProductUri(
contentItem.getResourceUri()));
} catch (IOException | ResourceNotSupportedException e) {
throw new CatalogCommandRuntimeException(
"Unable to retrieve resource for " + contentItem.getId(), e);
} catch (ResourceNotFoundException e) {
continue;
}
writeToZip(zipFile, contentItem, resource);
exportedContentItems.add(contentItem);
if (!contentItem.getMetacardTag()
.equals("revision")) {
for (String derivedUri : contentItem.getDerivedUris()) {
URI uri;
try {
uri = new URI(derivedUri);
} catch (URISyntaxException e) {
LOGGER.debug(
"Uri [{}] is not a valid URI. Derived content will not be included in export",
derivedUri);
continue;
}
ResourceResponse derivedResource;
try {
derivedResource =
catalogFramework.getLocalResource(new ResourceRequestByProductUri(
uri));
} catch (IOException e) {
throw new CatalogCommandRuntimeException(
"Unable to retrieve resource for " + contentItem.getId(), e);
} catch (ResourceNotFoundException | ResourceNotSupportedException e) {
LOGGER.warn("Could not retreive resource [{}]", uri, e);
console.printf("%sUnable to retrieve resource for export : %s%s%n",
Ansi.ansi()
.fg(Ansi.Color.RED)
.toString(),
uri,
Ansi.ansi()
.reset()
.toString());
continue;
}
writeToZip(zipFile, contentItem, derivedResource);
}
}
}
return exportedContentItems;
}
private void doDelete(List<ExportItem> exportedItems, List<ExportItem> exportedContentItems) {
Instant start;
console.println("Starting delete");
start = Instant.now();
for (ExportItem exportedContentItem : exportedContentItems) {
try {
DeleteStorageRequestImpl deleteRequest =
new DeleteStorageRequestImpl(Collections.singletonList(new IdAndUriMetacard(
exportedContentItem.getId(),
exportedContentItem.getResourceUri())),
exportedContentItem.getId(),
Collections.emptyMap());
storageProvider.delete(deleteRequest);
storageProvider.commit(deleteRequest);
} catch (StorageException e) {
printErrorMessage(
"Could not delete content for metacard: " + exportedContentItem.toString());
}
}
for (ExportItem exported : exportedItems) {
try {
catalogProvider.delete(new DeleteRequestImpl(exported.getId()));
} catch (IngestException e) {
printErrorMessage("Could not delete metacard: " + exported.toString());
}
}
console.println("Metacards and Content deleted in: " + getFormattedDuration(start));
console.println("Number of metacards deleted: " + exportedItems.size());
console.println("Number of content deleted: " + exportedContentItems.size());
}
private void writeToZip(/*Mutable,IO*/ ZipFile zipFile, ExportItem exportItem,
ResourceResponse resource) throws ZipException {
ZipParameters parameters = new ZipParameters();
parameters.setSourceExternalStream(true);
String id = exportItem.getId();
String path = getContentPath(id, resource);
parameters.setFileNameInZip(path);
zipFile.addStream(resource.getResource()
.getInputStream(), parameters);
}
private String getContentPath(String id, ResourceResponse resource) {
String path = Paths.get("metacards", id.substring(0, 3), id)
.toString();
String fragment = ((URI) resource.getRequest()
.getAttributeValue()).getFragment();
if (fragment == null) { // is root content, put in root id folder
path = Paths.get(path,
"content",
resource.getResource()
.getName())
.toString();
} else { // is derived content, put in subfolder
path = Paths.get(path,
"derived",
fragment,
resource.getResource()
.getName())
.toString();
}
return path;
}
private void writeToZip(/*Mutable,IO*/ ZipFile zipFile, Result result) {
ZipParameters parameters = new ZipParameters();
parameters.setSourceExternalStream(true);
String id = result.getMetacard()
.getId();
parameters.setFileNameInZip(Paths.get("metacards",
id.substring(0, 3),
id,
"metacard",
id + ".xml")
.toString());
try {
BinaryContent binaryMetacard = transformer.transform(result.getMetacard(),
Collections.emptyMap());
zipFile.addStream(binaryMetacard.getInputStream(), parameters);
} catch (ZipException e) {
LOGGER.error("Error processing result and adding to ZIP", e);
throw new CatalogCommandRuntimeException(e);
} catch (CatalogTransformerException e) {
LOGGER.warn("Could not transform metacard. Metacard will not be added to zip [{}]",
result.getMetacard()
.getId());
console.printf(
"%sCould not transform metacard. Metacard will not be included in export. %s - %s%s%n",
Ansi.ansi()
.fg(Ansi.Color.RED)
.toString(),
result.getMetacard()
.getId(),
result.getMetacard()
.getTitle(),
Ansi.ansi()
.reset()
.toString());
}
}
/**
* Generates stateful predicate to filter distinct elements by a certain key in the object.
*
* @param keyExtractor Function to pull the desired key out of the object
* @return the stateful predicate
*/
private static <T> Predicate<T> distinctByKey(Function<? super T, ?> keyExtractor) {
Map<Object, Boolean> seen = new ConcurrentHashMap<>();
return t -> seen.putIfAbsent(keyExtractor.apply(t), Boolean.TRUE) == null;
}
private String getTag(Result r) {
Set<String> tags = r.getMetacard()
.getTags();
if (tags.contains("deleted")) {
return "deleted";
} else if (tags.contains("revision")) {
return "revision";
} else {
return "nonhistory";
}
}
private Filter initRevisionFilter() {
return filterBuilder.attribute(Metacard.TAGS)
.is()
.like()
.text("revision");
}
private Filter getHistoryFilter(Result result) {
String id;
String typeName = result.getMetacard()
.getMetacardType()
.getName();
switch (typeName) {
case DeletedMetacard.PREFIX:
id = String.valueOf(result.getMetacard()
.getAttribute("metacard.deleted.id")
.getValue());
break;
case MetacardVersion.PREFIX:
return null;
default:
id = result.getMetacard()
.getId();
break;
}
return filterBuilder.allOf(revisionFilter,
filterBuilder.attribute("metacard.version.id")
.is()
.equalTo()
.text(id));
}
protected Filter getFilter() throws InterruptedException, ParseException, CQLException {
Filter filter = super.getFilter();
if (archived) {
filter = filterBuilder.allOf(filter,
filterBuilder.attribute(Metacard.TAGS)
.is()
.like()
.text("deleted"));
}
return filter;
}
private QueryRequestImpl getQuery(Filter filter, int index, int pageSize) {
return new QueryRequestImpl(new QueryImpl(filter,
index,
pageSize,
SortBy.NATURAL_ORDER,
false,
TimeUnit.MINUTES.toMillis(1)), new HashMap<>());
}
}