/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE file at the root of the source
* tree and available online at
*
* https://github.com/keeps/roda
*/
package org.roda.core.migration;
import java.io.IOException;
import java.lang.reflect.Modifier;
import java.nio.file.DirectoryStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Optional;
import java.util.Set;
import org.apache.commons.lang3.StringUtils;
import org.apache.solr.client.solrj.SolrClient;
import org.apache.solr.client.solrj.SolrRequest;
import org.apache.solr.client.solrj.SolrServerException;
import org.apache.solr.client.solrj.request.schema.SchemaRequest;
import org.apache.solr.common.util.NamedList;
import org.reflections.Reflections;
import org.roda.core.RodaCoreFactory;
import org.roda.core.common.XMLUtility;
import org.roda.core.data.common.RodaConstants;
import org.roda.core.data.exceptions.GenericException;
import org.roda.core.data.exceptions.RODAException;
import org.roda.core.data.utils.JsonUtils;
import org.roda.core.data.v2.IsModelObject;
import org.roda.core.data.v2.ModelInfo;
import org.roda.core.data.v2.common.Pair;
import org.roda.core.data.v2.formats.Format;
import org.roda.core.data.v2.ip.metadata.PreservationMetadata;
import org.roda.core.data.v2.risks.Risk;
import org.roda.core.migration.model.FormatToVersion2;
import org.roda.core.migration.model.PreservationMetadataFileToVersion2;
import org.roda.core.migration.model.RiskToVersion2;
import org.roda.core.storage.fs.FSUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class MigrationManager {
private static final Logger LOGGER = LoggerFactory.getLogger(MigrationManager.class);
private Path modelInfoFile;
// map<model class, workflow>
private Map<String, MigrationWorkflow> modelMigrations = new HashMap<>();
public MigrationManager(Path dataFolder) {
super();
this.modelInfoFile = dataFolder.resolve("model.json");
}
// 20161031 hsilva: this method is not invoked in the constructor as it might
// get very big & therefore should be done in a lazy fashion
public void setupModelMigrations() throws GenericException {
addModelMigration(Risk.class, 2, RiskToVersion2.class);
addModelMigration(Format.class, 2, FormatToVersion2.class);
addModelMigration(PreservationMetadata.class, 2, PreservationMetadataFileToVersion2.class);
}
private <T extends IsModelObject> void addModelMigration(final Class<T> clazz, final int toVersion,
final Class<? extends MigrationAction<T>> migrationClass) throws GenericException {
String className = clazz.getName();
MigrationWorkflow classMigrations = modelMigrations.getOrDefault(className, new MigrationWorkflow());
// at the very last I'm updating pointers
modelMigrations.put(className, classMigrations);
try {
MigrationAction<T> migrationAction = migrationClass.newInstance();
if (migrationAction.isToVersionValid(toVersion)) {
classMigrations.addMigration(toVersion, migrationClass);
} else {
LOGGER.error(
"Trying to configure migration for model class '{}', setting toVersion to '{}' using action class '{}' but this class says the toVersion is not valid",
className, toVersion, migrationClass.getName());
throw new GenericException(
"Trying to configure migration for model class '" + className + "' with the wrong toVersion");
}
} catch (InstantiationException | IllegalAccessException e) {
LOGGER.error("Error instantiating migration action class '{}' (which migrates to version {})",
migrationClass.getName(), toVersion, e);
throw new GenericException("Error instantiating migration action class '" + migrationClass.getName()
+ "' (which migrates to version '" + toVersion + "')");
}
}
public boolean isNecessaryToPerformMigration(final SolrClient solrClient, final Optional<Path> tempIndexConfigsPath)
throws GenericException {
boolean migrationIsNecessary;
// check if model migration is necessary
migrationIsNecessary = isModelMigrationNecessary();
// check if index migration is necessary
migrationIsNecessary = migrationIsNecessary || isIndexMigrationNecessary(solrClient, tempIndexConfigsPath);
return migrationIsNecessary;
}
private boolean isModelMigrationNecessary() throws GenericException {
boolean migrationIsNecessary = false;
Map<String, Integer> modelClassesVersionsFromCode = getModelClassesVersionsFromCode(true, "Indexed");
Map<String, Integer> modelClassesVersionsInstalled = new HashMap<>();
if (FSUtils.exists(modelInfoFile)) {
modelClassesVersionsInstalled = JsonUtils.getObjectFromJson(modelInfoFile, ModelInfo.class)
.getInstalledClassesVersions();
}
if (modelClassesVersionsInstalled.isEmpty()) {
// no information, lets assume first RODA execution
LOGGER.info("No model info. available. Writing initial model info. to file {}", modelInfoFile);
JsonUtils.writeObjectToFile(new ModelInfo().setInstalledClassesVersions(modelClassesVersionsFromCode),
modelInfoFile);
} else {
// information exists in file, lets see if any migration is needed
Map<String, Integer> newModelClassesVersionsToBeWritten = new HashMap<>();
for (Entry<String, Integer> classVersionFromCode : modelClassesVersionsFromCode.entrySet()) {
String classFromCode = classVersionFromCode.getKey();
int versionFromCode = classVersionFromCode.getValue();
LOGGER.debug("Checking if model class '{}' requires to do a migration...", classFromCode);
// previous information about a class already exists
if (modelClassesVersionsInstalled.containsKey(classFromCode)) {
int versionInstalled = modelClassesVersionsInstalled.get(classFromCode);
if (versionInstalled != versionFromCode) {
LOGGER.warn(
"A migration may be needed! Model class '{}' version is set to {} in code & installed version is set to {}",
classFromCode, versionFromCode, versionInstalled);
migrationIsNecessary = true;
}
} else {
// class information does not exists, probably is new & therefore no
// model migration is needed
LOGGER.info(
"No migration is needed as no previous information about model class '{}' exists. Will write its information as it is new!",
classFromCode);
newModelClassesVersionsToBeWritten.put(classFromCode, versionFromCode);
}
}
// write (just) new classes
if (!newModelClassesVersionsToBeWritten.isEmpty()) {
modelClassesVersionsInstalled.putAll(newModelClassesVersionsToBeWritten);
LOGGER.info("Updating model info. with new classes. to file {}", modelInfoFile);
JsonUtils.writeObjectToFile(new ModelInfo().setInstalledClassesVersions(modelClassesVersionsInstalled),
modelInfoFile);
}
if (migrationIsNecessary) {
LOGGER.warn(
"A migration might be needed. But if you know what you're doing & realize that no migration is needed, write the following content in file '{}':{}{}{}{}",
modelInfoFile, System.lineSeparator(), System.lineSeparator(),
JsonUtils.getJsonFromObject(new ModelInfo().setInstalledClassesVersions(modelClassesVersionsFromCode)),
System.lineSeparator());
}
}
return migrationIsNecessary;
}
private Map<String, Integer> getModelClassesVersionsFromCode(final boolean avoidClassesByNamePrefix,
final String avoidByNamePrefix) {
Map<String, Integer> ret = new HashMap<>();
Reflections reflections = new Reflections("org.roda.core.data.v2");
Set<Class<? extends IsModelObject>> modelClasses = reflections.getSubTypesOf(IsModelObject.class);
for (Class<? extends IsModelObject> clazz : modelClasses) {
if (Modifier.isAbstract(clazz.getModifiers())
|| (avoidClassesByNamePrefix && clazz.getSimpleName().startsWith(avoidByNamePrefix))) {
continue;
}
try {
ret.put(clazz.getName(), clazz.newInstance().getClassVersion());
} catch (InstantiationException | IllegalAccessException e) {
LOGGER.error("Unable to determine class '{}' model version", clazz.getName(), e);
}
}
return ret;
}
public void performModelMigrations() throws GenericException {
ModelInfo modelInfo = JsonUtils.getObjectFromJson(modelInfoFile, ModelInfo.class);
// perform migrations
for (Entry<String, MigrationWorkflow> classMigrations : modelMigrations.entrySet()) {
String className = classMigrations.getKey();
MigrationWorkflow migrationWorkflow = classMigrations.getValue();
int installedVersion = modelInfo.getInstalledClassesVersions().getOrDefault(className, Integer.MAX_VALUE);
// see if there is no need to continue processing this particular class
// based on installed version (if any)
if (installedVersion >= migrationWorkflow.getLastToVersion()) {
continue;
}
LOGGER.info("Performing migration for class '{}'", className);
for (Pair<Integer, Class<? extends MigrationAction>> classMigration : migrationWorkflow.getMigrations()) {
Integer toVersion = classMigration.getFirst();
Class<? extends MigrationAction> migrationClass = classMigration.getSecond();
// see if there is no need to perform this particular migration
if (installedVersion >= toVersion) {
continue;
}
LOGGER.info("Migrating to version {} using class '{}'", toVersion, migrationClass.getName());
try {
// migrate
migrationClass.newInstance().migrate(RodaCoreFactory.getStorageService());
LOGGER.info("Migrated with success to version {}", toVersion);
// update class specific version after successful migration
modelInfo.getInstalledClassesVersions().put(className, toVersion);
} catch (InstantiationException | IllegalAccessException e) {
LOGGER.error("Error instantiating migration action class '{}' (which migrates to version {})",
migrationClass.getName(), toVersion, e);
break;
} catch (RODAException e) {
LOGGER.error("Error executing migration action '{}'. Stopping migrations for class '{}'.",
migrationClass.getName(), className);
break;
}
}
LOGGER.info("Done migrating class '{}'", className);
}
// update model info. file
JsonUtils.writeObjectToFile(modelInfo, modelInfoFile);
}
private boolean isIndexMigrationNecessary(SolrClient solrClient, Optional<Path> tempIndexConfigsPath)
throws GenericException {
boolean migrationIsNecessary = false;
if (tempIndexConfigsPath.isPresent()) {
Path indexConfigsFolder = tempIndexConfigsPath.get().resolve(RodaConstants.CORE_CONFIG_FOLDER)
.resolve(RodaConstants.CORE_INDEX_FOLDER);
List<String> solrCollections = getSolrCollections(indexConfigsFolder);
Map<String, Integer> indexVersionsFromCode = getIndexVersionsFromCode(indexConfigsFolder, solrCollections);
Map<String, Integer> indexVersionsInstalled = getIndexVersionsFromSolr(solrClient, solrCollections);
if (indexVersionsFromCode.isEmpty() || indexVersionsInstalled.isEmpty()) {
LOGGER.error("Unable to determine if index migration/migrations is/are needed");
throw new GenericException("Unable to determine if index migration/migrations is/are needed");
} else {
for (Entry<String, Integer> indexFromCode : indexVersionsFromCode.entrySet()) {
String collection = indexFromCode.getKey();
Integer collectionVersionFromCode = indexFromCode.getValue();
if (indexVersionsInstalled.containsKey(collection)) {
Integer collectionVersionInstalled = indexVersionsInstalled.get(collection);
if (!collectionVersionFromCode.equals(collectionVersionInstalled)) {
LOGGER.warn(
"A migration is needed! Collection '{}' version is set to {} in code schema.xml & installed version (Solr deployed) is set to {}",
collection, collectionVersionFromCode, collectionVersionInstalled);
migrationIsNecessary = true;
}
} else {
LOGGER.warn(
"A new collection called '{}' exists & needs to be installed before being able to start RODA properly",
collection);
migrationIsNecessary = true;
}
}
}
} else {
LOGGER.error("Unable to determine Solr collections via folder with index configs");
throw new GenericException("Unable to determine Solr collections via folder with index configs");
}
return migrationIsNecessary;
}
private List<String> getSolrCollections(Path indexConfigsFolder) {
List<String> solrCollections = new ArrayList<>();
try (DirectoryStream<Path> stream = Files.newDirectoryStream(indexConfigsFolder)) {
stream.forEach(e -> {
if (FSUtils.isDirectory(e)) {
solrCollections.add(e.getFileName().toString());
}
});
} catch (IOException e) {
// do nothing
}
return solrCollections;
}
private Map<String, Integer> getIndexVersionsFromCode(Path indexConfigsFolder, List<String> collections) {
Map<String, Integer> ret = new HashMap<>();
for (String collection : collections) {
Path schemaFile = indexConfigsFolder.resolve(collection).resolve("conf").resolve("schema.xml");
String version = XMLUtility.getStringFromFile(schemaFile, "/schema/@name").replaceFirst(".*-", "");
try {
ret.put(collection, Integer.parseInt(version));
} catch (NumberFormatException e) {
// do nothing
}
}
return ret;
}
private Map<String, Integer> getIndexVersionsFromSolr(SolrClient solrClient, List<String> collections) {
Map<String, Integer> ret = new HashMap<>();
SolrRequest request = new SchemaRequest.SchemaName();
try {
for (String collection : collections) {
NamedList<Object> response = solrClient.request(request, collection);
for (Entry<String, Object> entry : response) {
String value = entry.getValue().toString();
if ("name".equals(entry.getKey()) && StringUtils.isNotBlank(value)) {
String version = value.replaceFirst(".*-", "");
try {
ret.put(collection, Integer.parseInt(version));
} catch (NumberFormatException e) {
// do nothing
}
break;
}
}
}
} catch (SolrServerException | IOException e) {
// do nothing
}
return ret;
}
private class MigrationWorkflow {
private int lastToVersion = Integer.MIN_VALUE;
private List<Pair<Integer, Class<? extends MigrationAction>>> migrations = new ArrayList<>();
public void addMigration(int toVersion, Class<? extends MigrationAction> migrationActionClazz) {
if (lastToVersion < toVersion) {
lastToVersion = toVersion;
migrations.add(Pair.of(toVersion, migrationActionClazz));
} else {
LOGGER.error(
"Error trying to add a migration action class out of order (last toVersion added: {}; toVersion to be added: {})",
lastToVersion, toVersion);
throw new RuntimeException("Error trying to add a migration action class out of order");
}
}
public List<Pair<Integer, Class<? extends MigrationAction>>> getMigrations() {
return migrations;
}
public int getLastToVersion() {
return lastToVersion;
}
}
}