diff --git a/scm-core/src/main/java/sonia/scm/repository/AbstractSimpleRepositoryHandler.java b/scm-core/src/main/java/sonia/scm/repository/AbstractSimpleRepositoryHandler.java index 875fb55617..62dfd476af 100644 --- a/scm-core/src/main/java/sonia/scm/repository/AbstractSimpleRepositoryHandler.java +++ b/scm-core/src/main/java/sonia/scm/repository/AbstractSimpleRepositoryHandler.java @@ -59,9 +59,6 @@ public abstract class AbstractSimpleRepositoryHandler { - private static final String STORE_NAME = "repositories"; + public static final String STORE_NAME = "repository-paths"; private final SCMContextProvider contextProvider; private final InitialRepositoryLocationResolver initialRepositoryLocationResolver; diff --git a/scm-dao-xml/src/main/java/sonia/scm/repository/xml/XmlRepositoryDAO.java b/scm-dao-xml/src/main/java/sonia/scm/repository/xml/XmlRepositoryDAO.java index 9b3105b5ed..f506793d1a 100644 --- a/scm-dao-xml/src/main/java/sonia/scm/repository/xml/XmlRepositoryDAO.java +++ b/scm-dao-xml/src/main/java/sonia/scm/repository/xml/XmlRepositoryDAO.java @@ -95,10 +95,17 @@ public class XmlRepositoryDAO implements RepositoryDAO { @Override public void add(Repository repository) { + add(repository, repositoryLocationResolver.create(repository.getId())); + } + + public void add(Repository repository, Object location) { + if (!(location instanceof Path)) { + throw new IllegalArgumentException("can only handle locations of type " + Path.class.getName() + ", not of type " + location.getClass().getName()); + } Repository clone = repository.clone(); synchronized (this) { - Path repositoryPath = repositoryLocationResolver.create(repository.getId()); + Path repositoryPath = (Path) location; try { Path metadataPath = resolveDataPath(repositoryPath); @@ -111,10 +118,8 @@ public class XmlRepositoryDAO implements RepositoryDAO { byId.put(repository.getId(), clone); byNamespaceAndName.put(repository.getNamespaceAndName(), clone); } - } - @Override public boolean contains(Repository repository) { return byId.containsKey(repository.getId()); diff --git a/scm-dao-xml/src/test/java/sonia/scm/repository/xml/PathBasedRepositoryLocationResolverTest.java b/scm-dao-xml/src/test/java/sonia/scm/repository/xml/PathBasedRepositoryLocationResolverTest.java index f8b517e18b..5f758f124a 100644 --- a/scm-dao-xml/src/test/java/sonia/scm/repository/xml/PathBasedRepositoryLocationResolverTest.java +++ b/scm-dao-xml/src/test/java/sonia/scm/repository/xml/PathBasedRepositoryLocationResolverTest.java @@ -152,7 +152,7 @@ class PathBasedRepositoryLocationResolverTest { } private String getXmlFileContent() { - Path storePath = basePath.resolve("config").resolve("repositories.xml"); + Path storePath = basePath.resolve("config").resolve("repository-paths.xml"); assertThat(storePath).isRegularFile(); return content(storePath); diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/GitRepositoryHandlerTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/GitRepositoryHandlerTest.java index 66ec320067..2f5a8c4984 100644 --- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/GitRepositoryHandlerTest.java +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/GitRepositoryHandlerTest.java @@ -117,6 +117,6 @@ public class GitRepositoryHandlerTest extends SimpleRepositoryHandlerTestBase { initRepository(); File path = repositoryHandler.getDirectory(repository.getId()); - assertEquals(repoPath.toString() + File.separator + AbstractSimpleRepositoryHandler.REPOSITORIES_NATIVE_DIRECTORY, path.getAbsolutePath()); + assertEquals(repoPath.toString() + File.separator + RepositoryDirectoryHandler.REPOSITORIES_NATIVE_DIRECTORY, path.getAbsolutePath()); } } diff --git a/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/repository/HgRepositoryHandlerTest.java b/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/repository/HgRepositoryHandlerTest.java index c3b66525f9..c2ed5af42e 100644 --- a/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/repository/HgRepositoryHandlerTest.java +++ b/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/repository/HgRepositoryHandlerTest.java @@ -96,6 +96,6 @@ public class HgRepositoryHandlerTest extends SimpleRepositoryHandlerTestBase { initRepository(); File path = repositoryHandler.getDirectory(repository.getId()); - assertEquals(repoPath.toString() + File.separator + AbstractSimpleRepositoryHandler.REPOSITORIES_NATIVE_DIRECTORY, path.getAbsolutePath()); + assertEquals(repoPath.toString() + File.separator + RepositoryDirectoryHandler.REPOSITORIES_NATIVE_DIRECTORY, path.getAbsolutePath()); } } diff --git a/scm-plugins/scm-svn-plugin/src/test/java/sonia/scm/repository/SvnRepositoryHandlerTest.java b/scm-plugins/scm-svn-plugin/src/test/java/sonia/scm/repository/SvnRepositoryHandlerTest.java index c81c6311e1..932ff007e0 100644 --- a/scm-plugins/scm-svn-plugin/src/test/java/sonia/scm/repository/SvnRepositoryHandlerTest.java +++ b/scm-plugins/scm-svn-plugin/src/test/java/sonia/scm/repository/SvnRepositoryHandlerTest.java @@ -112,6 +112,6 @@ public class SvnRepositoryHandlerTest extends SimpleRepositoryHandlerTestBase { initRepository(); File path = repositoryHandler.getDirectory(repository.getId()); - assertEquals(repoPath.toString()+File.separator+ AbstractSimpleRepositoryHandler.REPOSITORIES_NATIVE_DIRECTORY, path.getAbsolutePath()); + assertEquals(repoPath.toString()+File.separator+ RepositoryDirectoryHandler.REPOSITORIES_NATIVE_DIRECTORY, path.getAbsolutePath()); } } diff --git a/scm-test/src/main/java/sonia/scm/repository/SimpleRepositoryHandlerTestBase.java b/scm-test/src/main/java/sonia/scm/repository/SimpleRepositoryHandlerTestBase.java index 000835156a..063681bbc4 100644 --- a/scm-test/src/main/java/sonia/scm/repository/SimpleRepositoryHandlerTestBase.java +++ b/scm-test/src/main/java/sonia/scm/repository/SimpleRepositoryHandlerTestBase.java @@ -113,7 +113,7 @@ public abstract class SimpleRepositoryHandlerTestBase extends AbstractTestBase { File repoDirectory = new File(baseDirectory, repository.getId()); repoPath = repoDirectory.toPath(); // when(repoDao.getPath(repository.getId())).thenReturn(repoPath); - return new File(repoDirectory, AbstractSimpleRepositoryHandler.REPOSITORIES_NATIVE_DIRECTORY); + return new File(repoDirectory, RepositoryDirectoryHandler.REPOSITORIES_NATIVE_DIRECTORY); } protected File baseDirectory; diff --git a/scm-test/src/main/java/sonia/scm/repository/spi/ZippedRepositoryTestBase.java b/scm-test/src/main/java/sonia/scm/repository/spi/ZippedRepositoryTestBase.java index c7a3b8f4e9..99f91c7926 100644 --- a/scm-test/src/main/java/sonia/scm/repository/spi/ZippedRepositoryTestBase.java +++ b/scm-test/src/main/java/sonia/scm/repository/spi/ZippedRepositoryTestBase.java @@ -153,7 +153,12 @@ public abstract class ZippedRepositoryTestBase extends AbstractTestBase */ private void extract(File folder) throws IOException { - URL url = Resources.getResource(getZippedRepositoryResource()); + String zippedRepositoryResource = getZippedRepositoryResource(); + extract(folder, zippedRepositoryResource); + } + + public static void extract(File targetFolder, String zippedRepositoryResource) throws IOException { + URL url = Resources.getResource(zippedRepositoryResource); ZipInputStream zip = null; try @@ -164,7 +169,7 @@ public abstract class ZippedRepositoryTestBase extends AbstractTestBase while (entry != null) { - File file = new File(folder, entry.getName()); + File file = new File(targetFolder, entry.getName()); File parent = file.getParentFile(); if (!parent.exists()) diff --git a/scm-webapp/src/main/java/sonia/scm/repository/update/BaseMigrationStrategy.java b/scm-webapp/src/main/java/sonia/scm/repository/update/BaseMigrationStrategy.java new file mode 100644 index 0000000000..3ac0a6fd68 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/repository/update/BaseMigrationStrategy.java @@ -0,0 +1,60 @@ +package sonia.scm.repository.update; + +import sonia.scm.SCMContextProvider; +import sonia.scm.migration.UpdateException; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.stream.Stream; + +abstract class BaseMigrationStrategy implements MigrationStrategy.Instance { + + private final SCMContextProvider contextProvider; + + BaseMigrationStrategy(SCMContextProvider contextProvider) { + this.contextProvider = contextProvider; + } + + Path getSourceDataPath(String name, String type) { + return Arrays.stream(name.split("/")) + .reduce(getTypeDependentPath(type), (path, namePart) -> path.resolve(namePart), (p1, p2) -> p1); + } + + Path getTypeDependentPath(String type) { + return contextProvider.getBaseDirectory().toPath().resolve("repositories").resolve(type); + } + + Stream listSourceDirectory(Path sourceDirectory) { + try { + return Files.list(sourceDirectory); + } catch (IOException e) { + throw new UpdateException("could not read original directory", e); + } + } + + void createDataDirectory(Path target) { + try { + Files.createDirectories(target); + } catch (IOException e) { + throw new UpdateException("could not create data directory " + target, e); + } + } + + void moveFile(Path sourceFile, Path targetFile) { + try { + Files.move(sourceFile, targetFile); + } catch (IOException e) { + throw new UpdateException("could not move data file from " + sourceFile + " to " + targetFile, e); + } + } + + void copyFile(Path sourceFile, Path targetFile) { + try { + Files.copy(sourceFile, targetFile); + } catch (IOException e) { + throw new UpdateException("could not copy original file from " + sourceFile + " to " + targetFile, e); + } + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/repository/update/CopyMigrationStrategy.java b/scm-webapp/src/main/java/sonia/scm/repository/update/CopyMigrationStrategy.java new file mode 100644 index 0000000000..c5550b29af --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/repository/update/CopyMigrationStrategy.java @@ -0,0 +1,44 @@ +package sonia.scm.repository.update; + +import sonia.scm.SCMContextProvider; +import sonia.scm.repository.RepositoryDirectoryHandler; +import sonia.scm.repository.RepositoryLocationResolver; + +import javax.inject.Inject; +import java.nio.file.Files; +import java.nio.file.Path; + +class CopyMigrationStrategy extends BaseMigrationStrategy { + + private final RepositoryLocationResolver locationResolver; + + @Inject + public CopyMigrationStrategy(SCMContextProvider contextProvider, RepositoryLocationResolver locationResolver) { + super(contextProvider); + this.locationResolver = locationResolver; + } + + @Override + public Path migrate(String id, String name, String type) { + Path repositoryBasePath = locationResolver.forClass(Path.class).getLocation(id); + Path targetDataPath = repositoryBasePath + .resolve(RepositoryDirectoryHandler.REPOSITORIES_NATIVE_DIRECTORY); + Path sourceDataPath = getSourceDataPath(name, type); + copyData(sourceDataPath, targetDataPath); + return repositoryBasePath; + } + + private void copyData(Path sourceDirectory, Path targetDirectory) { + createDataDirectory(targetDirectory); + listSourceDirectory(sourceDirectory).forEach( + sourceFile -> { + Path targetFile = targetDirectory.resolve(sourceFile.getFileName()); + if (Files.isDirectory(sourceFile)) { + copyData(sourceFile, targetFile); + } else { + copyFile(sourceFile, targetFile); + } + } + ); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/repository/update/InlineMigrationStrategy.java b/scm-webapp/src/main/java/sonia/scm/repository/update/InlineMigrationStrategy.java new file mode 100644 index 0000000000..ace6a63ab8 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/repository/update/InlineMigrationStrategy.java @@ -0,0 +1,41 @@ +package sonia.scm.repository.update; + +import sonia.scm.SCMContextProvider; +import sonia.scm.repository.RepositoryDirectoryHandler; + +import javax.inject.Inject; +import java.nio.file.Files; +import java.nio.file.Path; + +class InlineMigrationStrategy extends BaseMigrationStrategy { + + @Inject + public InlineMigrationStrategy(SCMContextProvider contextProvider) { + super(contextProvider); + } + + @Override + public Path migrate(String id, String name, String type) { + Path repositoryBasePath = getSourceDataPath(name, type); + Path targetDataPath = repositoryBasePath + .resolve(RepositoryDirectoryHandler.REPOSITORIES_NATIVE_DIRECTORY); + moveData(repositoryBasePath, targetDataPath); + return repositoryBasePath; + } + + private void moveData(Path sourceDirectory, Path targetDirectory) { + createDataDirectory(targetDirectory); + listSourceDirectory(sourceDirectory) + .filter(sourceFile -> !targetDirectory.equals(sourceFile)) + .forEach( + sourceFile -> { + Path targetFile = targetDirectory.resolve(sourceFile.getFileName()); + if (Files.isDirectory(sourceFile)) { + moveData(sourceFile, targetFile); + } else { + moveFile(sourceFile, targetFile); + } + } + ); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/repository/update/MigrationStrategy.java b/scm-webapp/src/main/java/sonia/scm/repository/update/MigrationStrategy.java new file mode 100644 index 0000000000..6ff631208c --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/repository/update/MigrationStrategy.java @@ -0,0 +1,26 @@ +package sonia.scm.repository.update; + +import com.google.inject.Injector; + +import java.nio.file.Path; + +enum MigrationStrategy { + + COPY(CopyMigrationStrategy.class), + MOVE(MoveMigrationStrategy.class), + INLINE(InlineMigrationStrategy.class); + + private Class implementationClass; + + MigrationStrategy(Class implementationClass) { + this.implementationClass = implementationClass; + } + + Instance from(Injector injector) { + return injector.getInstance(implementationClass); + } + + interface Instance { + Path migrate(String id, String name, String type); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/repository/update/MigrationStrategyDao.java b/scm-webapp/src/main/java/sonia/scm/repository/update/MigrationStrategyDao.java new file mode 100644 index 0000000000..f98e697468 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/repository/update/MigrationStrategyDao.java @@ -0,0 +1,28 @@ +package sonia.scm.repository.update; + +import sonia.scm.store.ConfigurationStore; +import sonia.scm.store.ConfigurationStoreFactory; + +import javax.inject.Inject; +import java.util.Optional; + +public class MigrationStrategyDao { + + private final RepositoryMigrationPlan plan; + private final ConfigurationStore store; + + @Inject + public MigrationStrategyDao(ConfigurationStoreFactory storeFactory) { + store = storeFactory.withType(RepositoryMigrationPlan.class).withName("migration-plan").build(); + this.plan = store.getOptional().orElse(new RepositoryMigrationPlan()); + } + + public Optional get(String id) { + return plan.get(id); + } + + public void set(String repositoryId, MigrationStrategy strategy) { + plan.set(repositoryId, strategy); + store.set(plan); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/repository/update/MoveMigrationStrategy.java b/scm-webapp/src/main/java/sonia/scm/repository/update/MoveMigrationStrategy.java new file mode 100644 index 0000000000..e42d4138bb --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/repository/update/MoveMigrationStrategy.java @@ -0,0 +1,75 @@ +package sonia.scm.repository.update; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import sonia.scm.SCMContextProvider; +import sonia.scm.repository.RepositoryDirectoryHandler; +import sonia.scm.repository.RepositoryLocationResolver; + +import javax.inject.Inject; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static java.util.Arrays.asList; + +class MoveMigrationStrategy extends BaseMigrationStrategy { + + private static final Logger LOG = LoggerFactory.getLogger(MoveMigrationStrategy.class); + + private final RepositoryLocationResolver locationResolver; + + @Inject + public MoveMigrationStrategy(SCMContextProvider contextProvider, RepositoryLocationResolver locationResolver) { + super(contextProvider); + this.locationResolver = locationResolver; + } + + @Override + public Path migrate(String id, String name, String type) { + Path repositoryBasePath = locationResolver.forClass(Path.class).getLocation(id); + Path targetDataPath = repositoryBasePath + .resolve(RepositoryDirectoryHandler.REPOSITORIES_NATIVE_DIRECTORY); + Path sourceDataPath = getSourceDataPath(name, type); + moveData(sourceDataPath, targetDataPath); + deleteOldDataDir(getTypeDependentPath(type), name); + return repositoryBasePath; + } + + private void deleteOldDataDir(Path rootPath, String name) { + delete(rootPath, asList(name.split("/"))); + } + + private void delete(Path rootPath, List directories) { + if (directories.isEmpty()) { + return; + } + Path directory = rootPath.resolve(directories.get(0)); + delete(directory, directories.subList(1, directories.size())); + try { + Files.deleteIfExists(directory); + } catch (IOException e) { + LOG.warn("could not delete source repository directory {}", directory); + } + } + + private void moveData(Path sourceDirectory, Path targetDirectory) { + createDataDirectory(targetDirectory); + listSourceDirectory(sourceDirectory).forEach( + sourceFile -> { + Path targetFile = targetDirectory.resolve(sourceFile.getFileName()); + if (Files.isDirectory(sourceFile)) { + moveData(sourceFile, targetFile); + } else { + moveFile(sourceFile, targetFile); + } + } + ); + try { + Files.delete(sourceDirectory); + } catch (IOException e) { + LOG.warn("could not delete source repository directory {}", sourceDirectory); + } + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/repository/update/RepositoryMigrationPlan.java b/scm-webapp/src/main/java/sonia/scm/repository/update/RepositoryMigrationPlan.java new file mode 100644 index 0000000000..126bca8a23 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/repository/update/RepositoryMigrationPlan.java @@ -0,0 +1,69 @@ +package sonia.scm.repository.update; + +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; +import javax.xml.bind.annotation.XmlRootElement; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import static java.util.Arrays.asList; + +@XmlAccessorType(XmlAccessType.FIELD) +@XmlRootElement(name = "repository-migration") +class RepositoryMigrationPlan { + + private List entries; + + RepositoryMigrationPlan() { + this(new RepositoryEntry[0]); + } + + RepositoryMigrationPlan(RepositoryEntry... entries) { + this.entries = new ArrayList<>(asList(entries)); + } + + Optional get(String repositoryId) { + return findEntry(repositoryId) + .map(RepositoryEntry::getDataMigrationStrategy); + } + + public void set(String repositoryId, MigrationStrategy strategy) { + Optional entry = findEntry(repositoryId); + if (entry.isPresent()) { + entry.get().setStrategy(strategy); + } else { + entries.add(new RepositoryEntry(repositoryId, strategy)); + } + } + + private Optional findEntry(String repositoryId) { + return entries.stream() + .filter(repositoryEntry -> repositoryId.equals(repositoryEntry.repositoryId)) + .findFirst(); + } + + @XmlRootElement(name = "entries") + @XmlAccessorType(XmlAccessType.FIELD) + static class RepositoryEntry { + + private String repositoryId; + private MigrationStrategy dataMigrationStrategy; + + RepositoryEntry() { + } + + RepositoryEntry(String repositoryId, MigrationStrategy dataMigrationStrategy) { + this.repositoryId = repositoryId; + this.dataMigrationStrategy = dataMigrationStrategy; + } + + public MigrationStrategy getDataMigrationStrategy() { + return dataMigrationStrategy; + } + + private void setStrategy(MigrationStrategy strategy) { + this.dataMigrationStrategy = strategy; + } + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/repository/update/XmlRepositoryFileNameUpdateStep.java b/scm-webapp/src/main/java/sonia/scm/repository/update/XmlRepositoryFileNameUpdateStep.java new file mode 100644 index 0000000000..765f3c6317 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/repository/update/XmlRepositoryFileNameUpdateStep.java @@ -0,0 +1,56 @@ +package sonia.scm.repository.update; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import sonia.scm.SCMContextProvider; +import sonia.scm.migration.UpdateStep; +import sonia.scm.plugin.Extension; +import sonia.scm.repository.xml.PathBasedRepositoryLocationResolver; +import sonia.scm.store.StoreConstants; +import sonia.scm.version.Version; + +import javax.inject.Inject; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import static sonia.scm.version.Version.parse; + +/** + * Moves an existing repositories.xml file to repository-paths.xml. + * Note that this has to run after an old v1 repository database has been migrated to v2 + * (see {@link XmlRepositoryV1UpdateStep}). + */ +@Extension +public class XmlRepositoryFileNameUpdateStep implements UpdateStep { + + private static final Logger LOG = LoggerFactory.getLogger(XmlRepositoryFileNameUpdateStep.class); + + private final SCMContextProvider contextProvider; + + @Inject + public XmlRepositoryFileNameUpdateStep(SCMContextProvider contextProvider) { + this.contextProvider = contextProvider; + } + + @Override + public void doUpdate() throws IOException { + Path configDir = contextProvider.getBaseDirectory().toPath().resolve(StoreConstants.CONFIG_DIRECTORY_NAME); + Path oldRepositoriesFile = configDir.resolve("repositories.xml"); + Path newRepositoryPathsFile = configDir.resolve(PathBasedRepositoryLocationResolver.STORE_NAME + StoreConstants.FILE_EXTENSION); + if (Files.exists(oldRepositoriesFile)) { + LOG.info("moving old repositories database files to repository-paths file"); + Files.move(oldRepositoriesFile, newRepositoryPathsFile); + } + } + + @Override + public Version getTargetVersion() { + return parse("2.0.1"); + } + + @Override + public String getAffectedDataType() { + return "sonia.scm.repository.xml"; + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/repository/update/XmlRepositoryV1UpdateStep.java b/scm-webapp/src/main/java/sonia/scm/repository/update/XmlRepositoryV1UpdateStep.java new file mode 100644 index 0000000000..813d42818f --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/repository/update/XmlRepositoryV1UpdateStep.java @@ -0,0 +1,259 @@ +package sonia.scm.repository.update; + +import com.google.inject.Injector; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import sonia.scm.SCMContextProvider; +import sonia.scm.migration.UpdateException; +import sonia.scm.migration.UpdateStep; +import sonia.scm.plugin.Extension; +import sonia.scm.repository.Repository; +import sonia.scm.repository.RepositoryPermission; +import sonia.scm.repository.xml.XmlRepositoryDAO; +import sonia.scm.store.ConfigurationEntryStore; +import sonia.scm.store.ConfigurationEntryStoreFactory; +import sonia.scm.store.StoreConstants; +import sonia.scm.version.Version; + +import javax.inject.Inject; +import javax.xml.bind.JAXBContext; +import javax.xml.bind.JAXBException; +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; +import javax.xml.bind.annotation.XmlElement; +import javax.xml.bind.annotation.XmlRootElement; +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +import static java.util.Optional.empty; +import static java.util.Optional.of; +import static sonia.scm.version.Version.parse; + +/** + * Migrates SCM-Manager v1 repository data structure to SCM-Manager v2 data structure. + * That is: + *
    + *
  • The old repositories.xml file is read
  • + *
  • For each repository in this database, + *
      + *
    • a new entry in the new repository-paths.xml database is written,
    • + *
    • the data directory is moved or copied to a SCM v2 consistent directory. How this is done + * can be specified by a strategy (@see {@link MigrationStrategy}), that has to be set in + * a database file named migration-plan.xml
    • (to create this file, use {@link MigrationStrategyDao}), + * and + *
    • the new metadata.xml file is created.
    • + *
    + *
  • + *
+ */ +@Extension +public class XmlRepositoryV1UpdateStep implements UpdateStep { + + private static Logger LOG = LoggerFactory.getLogger(XmlRepositoryV1UpdateStep.class); + + private final SCMContextProvider contextProvider; + private final XmlRepositoryDAO repositoryDao; + private final MigrationStrategyDao migrationStrategyDao; + private final Injector injector; + private final ConfigurationEntryStore propertyStore; + + @Inject + public XmlRepositoryV1UpdateStep( + SCMContextProvider contextProvider, + XmlRepositoryDAO repositoryDao, + MigrationStrategyDao migrationStrategyDao, + Injector injector, + ConfigurationEntryStoreFactory configurationEntryStoreFactory + ) { + this.contextProvider = contextProvider; + this.repositoryDao = repositoryDao; + this.migrationStrategyDao = migrationStrategyDao; + this.injector = injector; + this.propertyStore = configurationEntryStoreFactory + .withType(V1Properties.class) + .withName("repository-properties-v1") + .build(); + } + + @Override + public Version getTargetVersion() { + return parse("2.0.0"); + } + + @Override + public String getAffectedDataType() { + return "sonia.scm.repository.xml"; + } + + @Override + public void doUpdate() throws JAXBException { + if (!determineV1File().exists()) { + LOG.info("no v1 repositories database file found"); + return; + } + JAXBContext jaxbContext = JAXBContext.newInstance(V1RepositoryDatabase.class); + readV1Database(jaxbContext).ifPresent( + v1Database -> { + v1Database.repositoryList.repositories.forEach(this::readMigrationStrategy); + v1Database.repositoryList.repositories.forEach(this::update); + backupOldRepositoriesFile(); + } + ); + } + + private void backupOldRepositoriesFile() { + Path configDir = contextProvider.getBaseDirectory().toPath().resolve(StoreConstants.CONFIG_DIRECTORY_NAME); + Path oldRepositoriesFile = configDir.resolve("repositories.xml"); + Path backupFile = configDir.resolve("repositories.xml.v1.backup"); + LOG.info("moving old repositories database files to backup file {}", backupFile); + try { + Files.move(oldRepositoriesFile, backupFile); + } catch (IOException e) { + throw new UpdateException("could not backup old repository database file", e); + } + } + + private void update(V1Repository v1Repository) { + Path destination = handleDataDirectory(v1Repository); + Repository repository = new Repository( + v1Repository.id, + v1Repository.type, + getNamespace(v1Repository), + getName(v1Repository), + v1Repository.contact, + v1Repository.description, + createPermissions(v1Repository)); + LOG.info("creating new repository {} with id {} from old repository {} in directory {}", repository.getNamespaceAndName(), repository.getId(), v1Repository.name, destination); + repositoryDao.add(repository, destination); + propertyStore.put(v1Repository.id, v1Repository.properties); + } + + private Path handleDataDirectory(V1Repository v1Repository) { + MigrationStrategy dataMigrationStrategy = readMigrationStrategy(v1Repository); + return dataMigrationStrategy.from(injector).migrate(v1Repository.id, v1Repository.name, v1Repository.type); + } + + private MigrationStrategy readMigrationStrategy(V1Repository v1Repository) { + return migrationStrategyDao.get(v1Repository.id) + .orElseThrow(() -> new IllegalStateException("no strategy found for repository with id " + v1Repository.id + " and name " + v1Repository.name)); + } + + private RepositoryPermission[] createPermissions(V1Repository v1Repository) { + if (v1Repository.permissions == null) { + return new RepositoryPermission[0]; + } + return v1Repository.permissions + .stream() + .map(this::createPermission) + .toArray(RepositoryPermission[]::new); + } + + private RepositoryPermission createPermission(V1Permission v1Permission) { + LOG.info("creating permission {} for {}", v1Permission.type, v1Permission.name); + return new RepositoryPermission(v1Permission.name, v1Permission.type, v1Permission.groupPermission); + } + + private String getNamespace(V1Repository v1Repository) { + String[] nameParts = getNameParts(v1Repository.name); + return nameParts.length > 1 ? nameParts[0] : v1Repository.type; + } + + private String getName(V1Repository v1Repository) { + String[] nameParts = getNameParts(v1Repository.name); + return nameParts.length == 1 ? nameParts[0] : concatPathElements(nameParts); + } + + private String concatPathElements(String[] nameParts) { + return Arrays.stream(nameParts).skip(1).collect(Collectors.joining("_")); + } + + private String[] getNameParts(String v1Name) { + return v1Name.split("/"); + } + + private Optional readV1Database(JAXBContext jaxbContext) throws JAXBException { + Object unmarshal = jaxbContext.createUnmarshaller().unmarshal(determineV1File()); + if (unmarshal instanceof V1RepositoryDatabase) { + return of((V1RepositoryDatabase) unmarshal); + } else { + return empty(); + } + } + + private File determineV1File() { + File configDirectory = new File(contextProvider.getBaseDirectory(), StoreConstants.CONFIG_DIRECTORY_NAME); + return new File(configDirectory, "repositories" + StoreConstants.FILE_EXTENSION); + } + + @XmlAccessorType(XmlAccessType.FIELD) + @XmlRootElement(name = "permissions") + private static class V1Permission { + private boolean groupPermission; + private String name; + private String type; + } + + @XmlAccessorType(XmlAccessType.FIELD) + private static class V1Property { + private String key; + private String value; + } + + @XmlAccessorType(XmlAccessType.FIELD) + @XmlRootElement(name = "properties") + private static class V1Properties { + @XmlElement(name = "item") + private List properties; + } + + @XmlAccessorType(XmlAccessType.FIELD) + @XmlRootElement(name = "repositories") + private static class V1Repository { + private String contact; + private long creationDate; + private Long lastModified; + private String description; + private String id; + private String name; + private boolean isPublic; + private boolean archived; + private String type; + private List permissions; + private V1Properties properties; + + @Override + public String toString() { + return "V1Repository{" + + ", contact='" + contact + '\'' + + ", creationDate=" + creationDate + + ", lastModified=" + lastModified + + ", description='" + description + '\'' + + ", id='" + id + '\'' + + ", name='" + name + '\'' + + ", isPublic=" + isPublic + + ", archived=" + archived + + ", type='" + type + '\'' + + '}'; + } + } + + private static class RepositoryList { + @XmlElement(name = "repository") + private List repositories; + } + + @XmlRootElement(name = "repository-db") + @XmlAccessorType(XmlAccessType.FIELD) + private static class V1RepositoryDatabase { + private long creationTime; + private Long lastModified; + @XmlElement(name = "repositories") + private RepositoryList repositoryList; + } +} diff --git a/scm-webapp/src/test/java/sonia/scm/repository/update/CopyMigrationStrategyTest.java b/scm-webapp/src/test/java/sonia/scm/repository/update/CopyMigrationStrategyTest.java new file mode 100644 index 0000000000..d7217bec48 --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/repository/update/CopyMigrationStrategyTest.java @@ -0,0 +1,89 @@ +package sonia.scm.repository.update; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junitpioneer.jupiter.TempDirectory; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import sonia.scm.SCMContextProvider; +import sonia.scm.repository.RepositoryLocationResolver; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.fail; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@ExtendWith(TempDirectory.class) +@ExtendWith(MockitoExtension.class) +class CopyMigrationStrategyTest { + + @Mock + SCMContextProvider contextProvider; + @Mock + RepositoryLocationResolver locationResolver; + + @BeforeEach + void mockContextProvider(@TempDirectory.TempDir Path tempDir) { + when(contextProvider.getBaseDirectory()).thenReturn(tempDir.toFile()); + } + + @BeforeEach + void createV1Home(@TempDirectory.TempDir Path tempDir) throws IOException { + V1RepositoryFileSystem.createV1Home(tempDir); + } + + @BeforeEach + void mockLocationResolver(@TempDirectory.TempDir Path tempDir) { + RepositoryLocationResolver.RepositoryLocationResolverInstance instanceMock = mock(RepositoryLocationResolver.RepositoryLocationResolverInstance.class); + when(locationResolver.forClass(Path.class)).thenReturn(instanceMock); + when(instanceMock.getLocation(anyString())).thenAnswer(invocation -> tempDir.resolve((String) invocation.getArgument(0))); + } + + @Test + void shouldUseStandardDirectory(@TempDirectory.TempDir Path tempDir) { + Path target = new CopyMigrationStrategy(contextProvider, locationResolver).migrate("b4f-a9f0-49f7-ad1f-37d3aae1c55f", "some/more/directories/than/one", "git"); + assertThat(target).isEqualTo(tempDir.resolve("b4f-a9f0-49f7-ad1f-37d3aae1c55f")); + } + + @Test + void shouldCopyDataDirectory(@TempDirectory.TempDir Path tempDir) { + Path target = new CopyMigrationStrategy(contextProvider, locationResolver).migrate("b4f-a9f0-49f7-ad1f-37d3aae1c55f", "some/more/directories/than/one", "git"); + assertThat(target.resolve("data")).exists(); + Path originalDataDir = tempDir + .resolve("repositories") + .resolve("git") + .resolve("some") + .resolve("more") + .resolve("directories") + .resolve("than") + .resolve("one"); + assertDirectoriesEqual(target.resolve("data"), originalDataDir); + } + + private void assertDirectoriesEqual(Path targetDataDir, Path originalDataDir) { + Stream list = null; + try { + list = Files.list(originalDataDir); + } catch (IOException e) { + fail("could not read original directory", e); + } + list.forEach( + original -> { + Path expectedTarget = targetDataDir.resolve(original.getFileName()); + assertThat(expectedTarget).exists(); + if (Files.isDirectory(original)) { + assertDirectoriesEqual(expectedTarget, original); + } else { + assertThat(expectedTarget).hasSameContentAs(original); + } + } + ); + } +} diff --git a/scm-webapp/src/test/java/sonia/scm/repository/update/InlineMigrationStrategyTest.java b/scm-webapp/src/test/java/sonia/scm/repository/update/InlineMigrationStrategyTest.java new file mode 100644 index 0000000000..ed0d7eeb40 --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/repository/update/InlineMigrationStrategyTest.java @@ -0,0 +1,49 @@ +package sonia.scm.repository.update; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junitpioneer.jupiter.TempDirectory; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import sonia.scm.SCMContextProvider; + +import java.io.IOException; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +@ExtendWith(TempDirectory.class) +@ExtendWith(MockitoExtension.class) +class InlineMigrationStrategyTest { + + @Mock + SCMContextProvider contextProvider; + + @BeforeEach + void mockContextProvider(@TempDirectory.TempDir Path tempDir) { + when(contextProvider.getBaseDirectory()).thenReturn(tempDir.toFile()); + } + + @BeforeEach + void createV1Home(@TempDirectory.TempDir Path tempDir) throws IOException { + V1RepositoryFileSystem.createV1Home(tempDir); + } + + @Test + void shouldUseExistingDirectory(@TempDirectory.TempDir Path tempDir) { + Path target = new InlineMigrationStrategy(contextProvider).migrate("b4f-a9f0-49f7-ad1f-37d3aae1c55f", "some/more/directories/than/one", "git"); + assertThat(target).isEqualTo(resolveOldDirectory(tempDir)); + } + + @Test + void shouldMoveDataDirectory(@TempDirectory.TempDir Path tempDir) { + new InlineMigrationStrategy(contextProvider).migrate("b4f-a9f0-49f7-ad1f-37d3aae1c55f", "some/more/directories/than/one", "git"); + assertThat(resolveOldDirectory(tempDir).resolve("data")).exists(); + } + + private Path resolveOldDirectory(Path tempDir) { + return tempDir.resolve("repositories").resolve("git").resolve("some").resolve("more").resolve("directories").resolve("than").resolve("one"); + } +} diff --git a/scm-webapp/src/test/java/sonia/scm/repository/update/MigrationStrategyDaoTest.java b/scm-webapp/src/test/java/sonia/scm/repository/update/MigrationStrategyDaoTest.java new file mode 100644 index 0000000000..a7365ce656 --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/repository/update/MigrationStrategyDaoTest.java @@ -0,0 +1,75 @@ +package sonia.scm.repository.update; + +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junitpioneer.jupiter.TempDirectory; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import sonia.scm.SCMContextProvider; +import sonia.scm.store.ConfigurationStoreFactory; +import sonia.scm.store.JAXBConfigurationStoreFactory; + +import javax.xml.bind.JAXBException; +import java.nio.file.Path; +import java.util.Optional; + +import static org.mockito.Mockito.when; +import static sonia.scm.repository.update.MigrationStrategy.INLINE; + +@ExtendWith(MockitoExtension.class) +@ExtendWith(TempDirectory.class) +class MigrationStrategyDaoTest { + + @Mock + SCMContextProvider contextProvider; + + private ConfigurationStoreFactory storeFactory; + + @BeforeEach + void initStore(@TempDirectory.TempDir Path tempDir) { + when(contextProvider.getBaseDirectory()).thenReturn(tempDir.toFile()); + storeFactory = new JAXBConfigurationStoreFactory(contextProvider, null); + } + + @Test + void shouldReturnEmptyOptionalWhenStoreIsEmpty() throws JAXBException { + MigrationStrategyDao dao = new MigrationStrategyDao(storeFactory); + + Optional strategy = dao.get("any"); + + Assertions.assertThat(strategy).isEmpty(); + } + + @Test + void shouldReturnNewValue() throws JAXBException { + MigrationStrategyDao dao = new MigrationStrategyDao(storeFactory); + + dao.set("id", INLINE); + + Optional strategy = dao.get("id"); + + Assertions.assertThat(strategy).contains(INLINE); + } + + @Nested + class WithExistingDatabase { + @BeforeEach + void initExistingDatabase() throws JAXBException { + MigrationStrategyDao dao = new MigrationStrategyDao(storeFactory); + + dao.set("id", INLINE); + } + + @Test + void shouldFindExistingValue() throws JAXBException { + MigrationStrategyDao dao = new MigrationStrategyDao(storeFactory); + + Optional strategy = dao.get("id"); + + Assertions.assertThat(strategy).contains(INLINE); + } + } +} diff --git a/scm-webapp/src/test/java/sonia/scm/repository/update/MigrationStrategyMock.java b/scm-webapp/src/test/java/sonia/scm/repository/update/MigrationStrategyMock.java new file mode 100644 index 0000000000..e0ee39880f --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/repository/update/MigrationStrategyMock.java @@ -0,0 +1,25 @@ +package sonia.scm.repository.update; + +import com.google.inject.Injector; +import sonia.scm.repository.update.MigrationStrategy.Instance; + +import java.util.HashMap; +import java.util.Map; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class MigrationStrategyMock { + + static Injector init() { + Map mocks = new HashMap<>(); + Injector mock = mock(Injector.class); + when( + mock.getInstance(any(Class.class))) + .thenAnswer( + invocationOnMock -> mocks.computeIfAbsent(invocationOnMock.getArgument(0), key -> mock((Class) key)) + ); + return mock; + } +} diff --git a/scm-webapp/src/test/java/sonia/scm/repository/update/MoveMigrationStrategyTest.java b/scm-webapp/src/test/java/sonia/scm/repository/update/MoveMigrationStrategyTest.java new file mode 100644 index 0000000000..ce894516df --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/repository/update/MoveMigrationStrategyTest.java @@ -0,0 +1,62 @@ +package sonia.scm.repository.update; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junitpioneer.jupiter.TempDirectory; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import sonia.scm.SCMContextProvider; +import sonia.scm.repository.RepositoryLocationResolver; + +import java.io.IOException; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@ExtendWith(TempDirectory.class) +@ExtendWith(MockitoExtension.class) +class MoveMigrationStrategyTest { + + @Mock + SCMContextProvider contextProvider; + @Mock + RepositoryLocationResolver locationResolver; + + @BeforeEach + void mockContextProvider(@TempDirectory.TempDir Path tempDir) { + when(contextProvider.getBaseDirectory()).thenReturn(tempDir.toFile()); + } + + @BeforeEach + void createV1Home(@TempDirectory.TempDir Path tempDir) throws IOException { + V1RepositoryFileSystem.createV1Home(tempDir); + } + + @BeforeEach + void mockLocationResolver(@TempDirectory.TempDir Path tempDir) { + RepositoryLocationResolver.RepositoryLocationResolverInstance instanceMock = mock(RepositoryLocationResolver.RepositoryLocationResolverInstance.class); + when(locationResolver.forClass(Path.class)).thenReturn(instanceMock); + when(instanceMock.getLocation(anyString())).thenAnswer(invocation -> tempDir.resolve((String) invocation.getArgument(0))); + } + + @Test + void shouldUseStandardDirectory(@TempDirectory.TempDir Path tempDir) { + Path target = new MoveMigrationStrategy(contextProvider, locationResolver).migrate("b4f-a9f0-49f7-ad1f-37d3aae1c55f", "some/more/directories/than/one", "git"); + assertThat(target).isEqualTo(tempDir.resolve("b4f-a9f0-49f7-ad1f-37d3aae1c55f")); + } + + @Test + void shouldMoveDataDirectory(@TempDirectory.TempDir Path tempDir) { + Path target = new MoveMigrationStrategy(contextProvider, locationResolver).migrate("b4f-a9f0-49f7-ad1f-37d3aae1c55f", "some/more/directories/than/one", "git"); + assertThat(target.resolve("data")).exists(); + Path originalDataDir = tempDir + .resolve("repositories") + .resolve("git") + .resolve("some"); + assertThat(originalDataDir).doesNotExist(); + } +} diff --git a/scm-webapp/src/test/java/sonia/scm/repository/update/V1RepositoryFileSystem.java b/scm-webapp/src/test/java/sonia/scm/repository/update/V1RepositoryFileSystem.java new file mode 100644 index 0000000000..146953d790 --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/repository/update/V1RepositoryFileSystem.java @@ -0,0 +1,67 @@ +package sonia.scm.repository.update; + +import sonia.scm.repository.spi.ZippedRepositoryTestBase; + +import java.io.IOException; +import java.nio.file.Path; + +class V1RepositoryFileSystem { + /** + * Creates the following v1 repositories in the temp dir: + *
+   * 
+   *     
+   *     arthur@dent.uk
+   *     1558423492071
+   *     A repository with two folders.
+   *     3b91caa5-59c3-448f-920b-769aaa56b761
+   *     one/directory
+   *     false
+   *     false
+   *     git
+   * 
+   * 
+   *     
+   *     arthur@dent.uk
+   *     1558423543716
+   *     A repository in deeply nested folders.
+   *     c1597b4f-a9f0-49f7-ad1f-37d3aae1c55f
+   *     some/more/directories/than/one
+   *     false
+   *     false
+   *     git
+   * 
+   * 
+   *     
+   *     arthur@dent.uk
+   *     1558423440258
+   *     A simple repository without directories.
+   *     454972da-faf9-4437-b682-dc4a4e0aa8eb
+   *     1558425918578
+   *     simple
+   *     
+   *         true
+   *         mice
+   *         WRITE
+   *     
+   *     
+   *         false
+   *         dent
+   *         OWNER
+   *     
+   *     
+   *         false
+   *         trillian
+   *         READ
+   *     
+   *     false
+   *     false
+   *     git
+   *     http://localhost:8081/scm/git/simple
+   * 
+   * 
+ */ + static void createV1Home(Path tempDir) throws IOException { + ZippedRepositoryTestBase.extract(tempDir.toFile(), "sonia/scm/repository/update/scm-home.v1.zip"); + } +} diff --git a/scm-webapp/src/test/java/sonia/scm/repository/update/XmlRepositoryFileNameUpdateStepTest.java b/scm-webapp/src/test/java/sonia/scm/repository/update/XmlRepositoryFileNameUpdateStepTest.java new file mode 100644 index 0000000000..6314e02783 --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/repository/update/XmlRepositoryFileNameUpdateStepTest.java @@ -0,0 +1,44 @@ +package sonia.scm.repository.update; + +import com.google.common.io.Resources; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junitpioneer.jupiter.TempDirectory; +import sonia.scm.SCMContextProvider; +import sonia.scm.repository.xml.PathBasedRepositoryLocationResolver; + +import javax.xml.bind.JAXBException; +import java.io.IOException; +import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@ExtendWith(TempDirectory.class) +class XmlRepositoryFileNameUpdateStepTest { + + SCMContextProvider contextProvider = mock(SCMContextProvider.class); + + @BeforeEach + void mockScmHome(@TempDirectory.TempDir Path tempDir) { + when(contextProvider.getBaseDirectory()).thenReturn(tempDir.toFile()); + } + + @Test + void shouldCopyRepositoriesFileToRepositoryPathsFile(@TempDirectory.TempDir Path tempDir) throws JAXBException, IOException { + XmlRepositoryFileNameUpdateStep updateStep = new XmlRepositoryFileNameUpdateStep(contextProvider); + URL url = Resources.getResource("sonia/scm/repository/update/formerV2RepositoryFile.xml"); + Path configDir = tempDir.resolve("config"); + Files.createDirectories(configDir); + Files.copy(url.openStream(), configDir.resolve("repositories.xml")); + + updateStep.doUpdate(); + + assertThat(configDir.resolve(PathBasedRepositoryLocationResolver.STORE_NAME + ".xml")).exists(); + assertThat(configDir.resolve("repositories.xml")).doesNotExist(); + } +} diff --git a/scm-webapp/src/test/java/sonia/scm/repository/update/XmlRepositoryV1UpdateStepTest.java b/scm-webapp/src/test/java/sonia/scm/repository/update/XmlRepositoryV1UpdateStepTest.java new file mode 100644 index 0000000000..773d7bd447 --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/repository/update/XmlRepositoryV1UpdateStepTest.java @@ -0,0 +1,238 @@ +package sonia.scm.repository.update; + +import com.google.common.io.Resources; +import com.google.inject.Injector; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junitpioneer.jupiter.TempDirectory; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import sonia.scm.SCMContextProvider; +import sonia.scm.repository.Repository; +import sonia.scm.repository.RepositoryPermission; +import sonia.scm.repository.xml.XmlRepositoryDAO; +import sonia.scm.store.ConfigurationEntryStore; +import sonia.scm.store.ConfigurationEntryStoreFactory; +import sonia.scm.store.InMemoryConfigurationEntryStore; +import sonia.scm.store.InMemoryConfigurationEntryStoreFactory; + +import javax.xml.bind.JAXBException; +import java.io.IOException; +import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Optional; + +import static java.util.Optional.empty; +import static java.util.Optional.of; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static sonia.scm.repository.update.MigrationStrategy.COPY; +import static sonia.scm.repository.update.MigrationStrategy.INLINE; +import static sonia.scm.repository.update.MigrationStrategy.MOVE; + +@ExtendWith(MockitoExtension.class) +@ExtendWith(TempDirectory.class) +class XmlRepositoryV1UpdateStepTest { + + Injector injectorMock = MigrationStrategyMock.init(); + + @Mock + SCMContextProvider contextProvider; + @Mock + XmlRepositoryDAO repositoryDAO; + @Mock + MigrationStrategyDao migrationStrategyDao; + + ConfigurationEntryStoreFactory configurationEntryStoreFactory = new InMemoryConfigurationEntryStoreFactory(new InMemoryConfigurationEntryStore()); + + @Captor + ArgumentCaptor storeCaptor; + @Captor + ArgumentCaptor locationCaptor; + + XmlRepositoryV1UpdateStep updateStep; + + @BeforeEach + void mockScmHome(@TempDirectory.TempDir Path tempDir) { + when(contextProvider.getBaseDirectory()).thenReturn(tempDir.toFile()); + } + + @BeforeEach + void createUpdateStepFromMocks() { + updateStep = new XmlRepositoryV1UpdateStep( + contextProvider, + repositoryDAO, + migrationStrategyDao, + injectorMock, + configurationEntryStoreFactory + ); + } + + @Nested + class WithExistingDatabase { + + @BeforeEach + void createV1Home(@TempDirectory.TempDir Path tempDir) throws IOException { + V1RepositoryFileSystem.createV1Home(tempDir); + } + + @BeforeEach + void captureStoredRepositories() { + lenient().doNothing().when(repositoryDAO).add(storeCaptor.capture(), locationCaptor.capture()); + } + + @BeforeEach + void createMigrationPlan() { + lenient().when(migrationStrategyDao.get("3b91caa5-59c3-448f-920b-769aaa56b761")).thenReturn(of(MOVE)); + lenient().when(migrationStrategyDao.get("c1597b4f-a9f0-49f7-ad1f-37d3aae1c55f")).thenReturn(of(COPY)); + lenient().when(migrationStrategyDao.get("454972da-faf9-4437-b682-dc4a4e0aa8eb")).thenReturn(of(INLINE)); + } + + @Test + void shouldCreateNewRepositories() throws JAXBException { + updateStep.doUpdate(); + verify(repositoryDAO, times(3)).add(any(), any()); + } + + @Test + void shouldMapAttributes() throws JAXBException { + updateStep.doUpdate(); + + Optional repository = findByNamespace("git"); + + assertThat(repository) + .get() + .hasFieldOrPropertyWithValue("type", "git") + .hasFieldOrPropertyWithValue("contact", "arthur@dent.uk") + .hasFieldOrPropertyWithValue("description", "A simple repository without directories."); + } + + @Test + void shouldUseRepositoryTypeAsNamespaceForNamesWithSingleElement() throws JAXBException { + updateStep.doUpdate(); + + Optional repository = findByNamespace("git"); + + assertThat(repository) + .get() + .hasFieldOrPropertyWithValue("namespace", "git") + .hasFieldOrPropertyWithValue("name", "simple"); + } + + @Test + void shouldUseDirectoriesForNamespaceAndNameForNamesWithTwoElements() throws JAXBException { + updateStep.doUpdate(); + + Optional repository = findByNamespace("one"); + + assertThat(repository) + .get() + .hasFieldOrPropertyWithValue("namespace", "one") + .hasFieldOrPropertyWithValue("name", "directory"); + } + + @Test + void shouldUseDirectoriesForNamespaceAndConcatenatedNameForNamesWithMoreThanTwoElements() throws JAXBException { + updateStep.doUpdate(); + + Optional repository = findByNamespace("some"); + + assertThat(repository) + .get() + .hasFieldOrPropertyWithValue("namespace", "some") + .hasFieldOrPropertyWithValue("name", "more_directories_than_one"); + } + + @Test + void shouldMapPermissions() throws JAXBException { + updateStep.doUpdate(); + + Optional repository = findByNamespace("git"); + + assertThat(repository.get().getPermissions()) + .hasSize(3) + .contains( + new RepositoryPermission("mice", "WRITE", true), + new RepositoryPermission("dent", "OWNER", false), + new RepositoryPermission("trillian", "READ", false) + ); + } + + @Test + void shouldExtractPropertiesFromRepositories() throws JAXBException { + updateStep.doUpdate(); + + ConfigurationEntryStore store = configurationEntryStoreFactory.withType(null).withName("").build(); + assertThat(store.getAll()) + .hasSize(3); + } + + @Test + void shouldUseDirectoryFromStrategy(@TempDirectory.TempDir Path tempDir) throws JAXBException { + Path targetDir = tempDir.resolve("someDir"); + MigrationStrategy.Instance strategyMock = injectorMock.getInstance(InlineMigrationStrategy.class); + when(strategyMock.migrate("454972da-faf9-4437-b682-dc4a4e0aa8eb", "simple", "git")).thenReturn(targetDir); + + updateStep.doUpdate(); + + assertThat(locationCaptor.getAllValues()).contains(targetDir); + } + + @Test + void shouldFailForMissingMigrationStrategy() { + lenient().when(migrationStrategyDao.get("c1597b4f-a9f0-49f7-ad1f-37d3aae1c55f")).thenReturn(empty()); + assertThrows(IllegalStateException.class, () -> updateStep.doUpdate()); + } + + @Test + void shouldBackupOldRepositoryDatabaseFile(@TempDirectory.TempDir Path tempDir) throws JAXBException { + updateStep.doUpdate(); + + assertThat(tempDir.resolve("config").resolve("repositories.xml")).doesNotExist(); + assertThat(tempDir.resolve("config").resolve("repositories.xml.v1.backup")).exists(); + } + } + + @Test + void shouldNotFailIfNoOldDatabaseExists() throws JAXBException { + updateStep.doUpdate(); + } + + @Test + void shouldNotFailIfFormerV2DatabaseExists(@TempDirectory.TempDir Path tempDir) throws JAXBException, IOException { + createFormerV2RepositoriesFile(tempDir); + + updateStep.doUpdate(); + } + + @Test + void shouldNotBackupFormerV2DatabaseFile(@TempDirectory.TempDir Path tempDir) throws JAXBException, IOException { + createFormerV2RepositoriesFile(tempDir); + + updateStep.doUpdate(); + + assertThat(tempDir.resolve("config").resolve("repositories.xml")).exists(); + assertThat(tempDir.resolve("config").resolve("repositories.xml.v1.backup")).doesNotExist(); + } + + private void createFormerV2RepositoriesFile(@TempDirectory.TempDir Path tempDir) throws IOException { + URL url = Resources.getResource("sonia/scm/repository/update/formerV2RepositoryFile.xml"); + Path configDir = tempDir.resolve("config"); + Files.createDirectories(configDir); + Files.copy(url.openStream(), configDir.resolve("repositories.xml")); + } + + private Optional findByNamespace(String namespace) { + return storeCaptor.getAllValues().stream().filter(r -> r.getNamespace().equals(namespace)).findFirst(); + } +} diff --git a/scm-webapp/src/test/resources/sonia/scm/repository/update/formerV2RepositoryFile.xml b/scm-webapp/src/test/resources/sonia/scm/repository/update/formerV2RepositoryFile.xml new file mode 100644 index 0000000000..ca21ead549 --- /dev/null +++ b/scm-webapp/src/test/resources/sonia/scm/repository/update/formerV2RepositoryFile.xml @@ -0,0 +1,4 @@ + + + repositories/C2RRHjjeL2 + diff --git a/scm-webapp/src/test/resources/sonia/scm/repository/update/scm-home.v1.zip b/scm-webapp/src/test/resources/sonia/scm/repository/update/scm-home.v1.zip new file mode 100644 index 0000000000..0d43f2f4d6 Binary files /dev/null and b/scm-webapp/src/test/resources/sonia/scm/repository/update/scm-home.v1.zip differ