From d0f8161220ee24f08fe251c046bf0bf4bba30bc8 Mon Sep 17 00:00:00 2001 From: Rene Pfeuffer Date: Fri, 13 Oct 2023 10:23:29 +0200 Subject: [PATCH] Add functionality to modify repository storage locations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The repository location resolver gets a new function that allows to change the location of a repository. Pushed-by: Rene Pfeuffer Co-authored-by: René Pfeuffer Committed-by: René Pfeuffer --- .../changelog/modify_repository_location.yaml | 2 + gradle/dependencies.gradle | 2 + .../InitialRepositoryLocationResolver.java | 18 ++- .../RepositoryLocationOverride.java | 34 ++++ .../RepositoryLocationResolver.java | 46 +++++- .../UpdateStepRepositoryMetadataAccess.java | 8 +- ...InitialRepositoryLocationResolverTest.java | 23 ++- scm-dao-xml/build.gradle | 3 + .../scm/repository/xml/MetadataStore.java | 6 +- .../PathBasedRepositoryLocationResolver.java | 146 ++++++++++++++---- .../scm/repository/xml/XmlRepositoryDAO.java | 32 +++- ...thBasedRepositoryLocationResolverTest.java | 68 ++++++++ .../XmlRepositoryDAOSynchronizationTest.java | 3 +- .../repository/xml/XmlRepositoryDAOTest.java | 36 ++++- .../scm/store/JAXBPropertyFileAccessTest.java | 3 +- .../main/java/sonia/scm/AbstractTestBase.java | 6 +- .../main/java/sonia/scm/ManagerTestBase.java | 11 +- scm-ui/ui-extensions/src/extensionPoints.tsx | 5 + .../repos/containers/RepositoryDangerZone.tsx | 8 + .../api/RepositoryStorageExceptionMapper.java | 58 +++++++ .../lifecycle/modules/BootstrapModule.java | 2 + .../main/resources/locales/de/plugins.json | 4 + .../main/resources/locales/en/plugins.json | 4 + 23 files changed, 470 insertions(+), 58 deletions(-) create mode 100644 gradle/changelog/modify_repository_location.yaml create mode 100644 scm-core/src/main/java/sonia/scm/repository/RepositoryLocationOverride.java create mode 100644 scm-webapp/src/main/java/sonia/scm/api/RepositoryStorageExceptionMapper.java diff --git a/gradle/changelog/modify_repository_location.yaml b/gradle/changelog/modify_repository_location.yaml new file mode 100644 index 0000000000..97544d5018 --- /dev/null +++ b/gradle/changelog/modify_repository_location.yaml @@ -0,0 +1,2 @@ +- type: added + description: Internal API to modify repository storage locations diff --git a/gradle/dependencies.gradle b/gradle/dependencies.gradle index 360a8944fd..0935f96b85 100644 --- a/gradle/dependencies.gradle +++ b/gradle/dependencies.gradle @@ -94,6 +94,8 @@ ext { guava: 'com.google.guava:guava:32.0.1-jre', commonsLang: 'commons-lang:commons-lang:2.6', commonsCompress: 'org.apache.commons:commons-compress:1.23.0', + commonsIo: 'commons-io:commons-io:2.13.0', + commonsLang3: 'org.apache.commons:commons-lang3:3.13.0', // security shiroCore: "org.apache.shiro:shiro-core:${shiroVersion}", diff --git a/scm-core/src/main/java/sonia/scm/repository/InitialRepositoryLocationResolver.java b/scm-core/src/main/java/sonia/scm/repository/InitialRepositoryLocationResolver.java index 560bfeeee2..9bb4266df8 100644 --- a/scm-core/src/main/java/sonia/scm/repository/InitialRepositoryLocationResolver.java +++ b/scm-core/src/main/java/sonia/scm/repository/InitialRepositoryLocationResolver.java @@ -21,13 +21,15 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - + package sonia.scm.repository; import com.google.common.base.CharMatcher; +import javax.inject.Inject; import java.nio.file.Path; import java.nio.file.Paths; +import java.util.Set; import static com.google.common.base.Preconditions.checkArgument; @@ -49,6 +51,13 @@ public class InitialRepositoryLocationResolver { private static final CharMatcher ID_MATCHER = CharMatcher.anyOf("/\\."); + private final Set repositoryLocationOverrides; + + @Inject + public InitialRepositoryLocationResolver(Set repositoryLocationOverrides) { + this.repositoryLocationOverrides = repositoryLocationOverrides; + } + /** * Returns the initial path to repository. * @@ -63,4 +72,11 @@ public class InitialRepositoryLocationResolver { return Paths.get(DEFAULT_REPOSITORY_PATH, repositoryId); } + public Path getPath(Repository repository) { + Path path = getPath(repository.getId()); + for (RepositoryLocationOverride o : repositoryLocationOverrides) { + path = o.overrideLocation(repository, path); + } + return path; + } } diff --git a/scm-core/src/main/java/sonia/scm/repository/RepositoryLocationOverride.java b/scm-core/src/main/java/sonia/scm/repository/RepositoryLocationOverride.java new file mode 100644 index 0000000000..787dbf7914 --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/repository/RepositoryLocationOverride.java @@ -0,0 +1,34 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.repository; + +import sonia.scm.plugin.ExtensionPoint; + +import java.nio.file.Path; + +@ExtensionPoint +public interface RepositoryLocationOverride { + Path overrideLocation(Repository repository, Path defaultPath); +} diff --git a/scm-core/src/main/java/sonia/scm/repository/RepositoryLocationResolver.java b/scm-core/src/main/java/sonia/scm/repository/RepositoryLocationResolver.java index 0cbe8b1e68..6ecc9fece9 100644 --- a/scm-core/src/main/java/sonia/scm/repository/RepositoryLocationResolver.java +++ b/scm-core/src/main/java/sonia/scm/repository/RepositoryLocationResolver.java @@ -24,6 +24,10 @@ package sonia.scm.repository; +import sonia.scm.ContextEntry; +import sonia.scm.ExceptionWithContext; + +import java.io.IOException; import java.util.function.BiConsumer; public abstract class RepositoryLocationResolver { @@ -62,6 +66,28 @@ public abstract class RepositoryLocationResolver { */ void setLocation(String repositoryId, T location); + /** + * Modifies the location for an existing repository. + * @param repositoryId The id of the new repository. + * @throws IllegalArgumentException if the new location equals the current location. + * @throws UnsupportedOperationException if the backing persistence layer does not support modification. + * @throws RepositoryStorageException if any occurs during the move. + */ + default void modifyLocation(String repositoryId, T location) throws RepositoryStorageException { + throw new UnsupportedOperationException("location modification not supported"); + } + + /** + * Modifies the location for an existing repository without removing the original location. + * @param repositoryId The id of the repository. + * @throws IllegalArgumentException if the new location equals the current location. + * @throws UnsupportedOperationException if the backing persistence layer does not support modification. + * @throws RepositoryStorageException if any occurs during the move. + */ + default void modifyLocationAndKeepOld(String repositoryId, T location) throws RepositoryStorageException { + throw new UnsupportedOperationException("location modification not supported"); + } + /** * Iterates all repository locations known to this resolver instance and calls the consumer giving the repository id * and its location for each repository. @@ -70,9 +96,27 @@ public abstract class RepositoryLocationResolver { void forAllLocations(BiConsumer consumer); } - public class LocationNotFoundException extends IllegalStateException { + public static class LocationNotFoundException extends IllegalStateException { public LocationNotFoundException(String repositoryId) { super("location for repository " + repositoryId + " does not exist"); } } + + public static class RepositoryStorageException extends RuntimeException { + public RepositoryStorageException(String message) { + super(message); + } + + public RepositoryStorageException(String message, Throwable cause) { + super(message, cause); + } + + public String getRootMessage() { + if (getCause() == null) { + return this.getMessage(); + } else { + return getCause().getMessage(); + } + } + } } diff --git a/scm-core/src/main/java/sonia/scm/update/UpdateStepRepositoryMetadataAccess.java b/scm-core/src/main/java/sonia/scm/update/UpdateStepRepositoryMetadataAccess.java index 8a3d4f6904..f67dff3b7c 100644 --- a/scm-core/src/main/java/sonia/scm/update/UpdateStepRepositoryMetadataAccess.java +++ b/scm-core/src/main/java/sonia/scm/update/UpdateStepRepositoryMetadataAccess.java @@ -21,7 +21,7 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - + package sonia.scm.update; import sonia.scm.repository.Repository; @@ -31,5 +31,11 @@ import sonia.scm.repository.Repository; * {@link sonia.scm.repository.RepositoryLocationResolver}. */ public interface UpdateStepRepositoryMetadataAccess { + /** + * Reads the repository from the given location. + * @param location the location to read from + * @return the repository + * @throws sonia.scm.repository.InternalRepositoryException if the repository could not be read + */ Repository read(T location); } diff --git a/scm-core/src/test/java/sonia/scm/repository/InitialRepositoryLocationResolverTest.java b/scm-core/src/test/java/sonia/scm/repository/InitialRepositoryLocationResolverTest.java index 12d6c2e9aa..3499450a0a 100644 --- a/scm-core/src/test/java/sonia/scm/repository/InitialRepositoryLocationResolverTest.java +++ b/scm-core/src/test/java/sonia/scm/repository/InitialRepositoryLocationResolverTest.java @@ -21,7 +21,7 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - + package sonia.scm.repository; import org.junit.jupiter.api.Assertions; @@ -32,19 +32,22 @@ import org.mockito.junit.jupiter.MockitoExtension; import java.io.File; import java.nio.file.Path; +import static java.util.Collections.emptySet; +import static java.util.Collections.singleton; import static org.assertj.core.api.Assertions.assertThat; @ExtendWith({MockitoExtension.class}) class InitialRepositoryLocationResolverTest { - private InitialRepositoryLocationResolver resolver = new InitialRepositoryLocationResolver(); + private final InitialRepositoryLocationResolver resolver = new InitialRepositoryLocationResolver(emptySet()); @Test void shouldComputeInitialPath() { Path path = resolver.getPath("42"); - assertThat(path).isRelative(); - assertThat(path.toString()).isEqualTo("repositories" + File.separator + "42"); + assertThat(path) + .isRelative() + .hasToString("repositories" + File.separator + "42"); } @Test @@ -66,4 +69,16 @@ class InitialRepositoryLocationResolverTest { void shouldThrowIllegalArgumentExceptionIfIdIsDot() { Assertions.assertThrows(IllegalArgumentException.class, () -> resolver.getPath(".")); } + + @Test + void shouldUseOverrideForRepository() { + InitialRepositoryLocationResolver resolver = new InitialRepositoryLocationResolver( + singleton((repository, defaultPath) -> defaultPath.resolve(repository.getId())) + ); + Path path = resolver.getPath(new Repository("42", "git", "space", "X")); + + assertThat(path) + .isRelative() + .hasToString("repositories" + File.separator + "42" + File.separator + "42"); + } } diff --git a/scm-dao-xml/build.gradle b/scm-dao-xml/build.gradle index 45a1d012e0..163f05e4c7 100644 --- a/scm-dao-xml/build.gradle +++ b/scm-dao-xml/build.gradle @@ -28,6 +28,9 @@ plugins { } dependencies { + implementation libraries.commonsIo + implementation libraries.commonsLang3 + api platform(project(':')) api project(':scm-core') diff --git a/scm-dao-xml/src/main/java/sonia/scm/repository/xml/MetadataStore.java b/scm-dao-xml/src/main/java/sonia/scm/repository/xml/MetadataStore.java index 2a6562e092..c30431e71c 100644 --- a/scm-dao-xml/src/main/java/sonia/scm/repository/xml/MetadataStore.java +++ b/scm-dao-xml/src/main/java/sonia/scm/repository/xml/MetadataStore.java @@ -54,12 +54,13 @@ public class MetadataStore implements UpdateStepRepositoryMetadataAccess { } } + @Override public Repository read(Path path) { LOG.trace("read repository metadata from {}", path); return compute(() -> { try { return (Repository) jaxbContext.createUnmarshaller().unmarshal(resolveDataPath(path).toFile()); - } catch (JAXBException ex) { + } catch (JAXBException | IllegalArgumentException ex) { throw new InternalRepositoryException( ContextEntry.ContextBuilder.entity(Path.class, path.toString()).build(), "failed read repository metadata", ex ); @@ -67,6 +68,9 @@ public class MetadataStore implements UpdateStepRepositoryMetadataAccess { }).withLockedFileForRead(path); } + /** + * Write the repository metadata to the given path. + */ void write(Path path, Repository repository) { LOG.trace("write repository metadata of {} to {}", repository, path); try { diff --git a/scm-dao-xml/src/main/java/sonia/scm/repository/xml/PathBasedRepositoryLocationResolver.java b/scm-dao-xml/src/main/java/sonia/scm/repository/xml/PathBasedRepositoryLocationResolver.java index 2e0a5f0cda..aeb608fea5 100644 --- a/scm-dao-xml/src/main/java/sonia/scm/repository/xml/PathBasedRepositoryLocationResolver.java +++ b/scm-dao-xml/src/main/java/sonia/scm/repository/xml/PathBasedRepositoryLocationResolver.java @@ -24,23 +24,27 @@ package sonia.scm.repository.xml; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import org.apache.commons.io.FileUtils; +import org.apache.commons.lang3.event.EventListenerSupport; import sonia.scm.SCMContextProvider; import sonia.scm.io.FileSystem; import sonia.scm.repository.BasicRepositoryLocationResolver; import sonia.scm.repository.InitialRepositoryLocationResolver; -import sonia.scm.repository.InternalRepositoryException; +import sonia.scm.repository.Repository; import sonia.scm.store.StoreConstants; import javax.inject.Inject; import javax.inject.Singleton; +import java.io.IOException; +import java.nio.file.Files; import java.nio.file.Path; import java.time.Clock; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.function.BiConsumer; -import static sonia.scm.ContextEntry.ContextBuilder.entity; - /** * A Location Resolver for File based Repository Storage. *

@@ -69,6 +73,8 @@ public class PathBasedRepositoryLocationResolver extends BasicRepositoryLocation private long creationTime; private long lastModified; + private EventListenerSupport maintenanceCallbacks = EventListenerSupport.create(MaintenanceCallback.class); + @Inject public PathBasedRepositoryLocationResolver(SCMContextProvider contextProvider, InitialRepositoryLocationResolver initialRepositoryLocationResolver, FileSystem fileSystem) { this(contextProvider, initialRepositoryLocationResolver, fileSystem, Clock.systemUTC()); @@ -90,50 +96,92 @@ public class PathBasedRepositoryLocationResolver extends BasicRepositoryLocation } @Override + @SuppressWarnings("unchecked") protected RepositoryLocationResolverInstance create(Class type) { - return new RepositoryLocationResolverInstance() { - @Override - public T getLocation(String repositoryId) { - if (pathById.containsKey(repositoryId)) { - return (T) contextProvider.resolve(pathById.get(repositoryId)); - } else { - throw new LocationNotFoundException(repositoryId); + if (type.isAssignableFrom(Path.class)) { + return (RepositoryLocationResolverInstance) new RepositoryLocationResolverInstance() { + @Override + public Path getLocation(String repositoryId) { + if (pathById.containsKey(repositoryId)) { + return contextProvider.resolve(pathById.get(repositoryId)); + } else { + throw new LocationNotFoundException(repositoryId); + } } - } - @Override - public T createLocation(String repositoryId) { - if (pathById.containsKey(repositoryId)) { - throw new IllegalStateException("location for repository " + repositoryId + " already exists"); - } else { - return (T) create(repositoryId); + @Override + public Path createLocation(String repositoryId) { + if (pathById.containsKey(repositoryId)) { + throw new IllegalStateException("location for repository " + repositoryId + " already exists"); + } else { + return create(repositoryId); + } } - } - @Override - public void setLocation(String repositoryId, T location) { - if (pathById.containsKey(repositoryId)) { - throw new IllegalStateException("location for repository " + repositoryId + " already exists"); - } else { - PathBasedRepositoryLocationResolver.this.setLocation(repositoryId, ((Path) location).toAbsolutePath()); + @Override + public void setLocation(String repositoryId, Path location) { + if (pathById.containsKey(repositoryId)) { + throw new IllegalStateException("location for repository " + repositoryId + " already exists"); + } else { + PathBasedRepositoryLocationResolver.this.setLocation(repositoryId, location.toAbsolutePath()); + } } - } - @Override - public void forAllLocations(BiConsumer consumer) { - pathById.forEach((id, path) -> consumer.accept(id, (T) contextProvider.resolve(path))); - } - }; + @Override + public void modifyLocation(String repositoryId, Path newPath) throws RepositoryStorageException { + modifyLocation(repositoryId, newPath, oldPath -> FileUtils.moveDirectory(contextProvider.resolve(oldPath).toFile(), newPath.toFile())); + } + + @Override + public void modifyLocationAndKeepOld(String repositoryId, Path newPath) throws RepositoryStorageException { + modifyLocation(repositoryId, newPath, oldPath -> FileUtils.copyDirectory(contextProvider.resolve(oldPath).toFile(), newPath.toFile())); + } + + private void modifyLocation(String repositoryId, Path newPath, Modifier modifier) throws RepositoryStorageException { + maintenanceCallbacks.fire().downForMaintenance(new DownForMaintenanceContext(repositoryId)); + Path oldPath = pathById.get(repositoryId); + pathById.remove(repositoryId); + try { + modifier.modify(contextProvider.resolve(oldPath)); + } catch (Exception e) { + PathBasedRepositoryLocationResolver.this.setLocation(repositoryId, oldPath); + maintenanceCallbacks.fire().upAfterMaintenance(new UpAfterMaintenanceContext(repositoryId, oldPath)); + throw new RepositoryStorageException("could not create repository at new path " + newPath, e); + } + PathBasedRepositoryLocationResolver.this.setLocation(repositoryId, newPath); + maintenanceCallbacks.fire().upAfterMaintenance(new UpAfterMaintenanceContext(repositoryId, newPath)); + } + + @Override + public void forAllLocations(BiConsumer consumer) { + pathById.forEach((id, path) -> consumer.accept(id, contextProvider.resolve(path))); + } + }; + } else { + throw new IllegalArgumentException("type not supported: " + type); + } + } + + Path create(Repository repository) { + Path path = initialRepositoryLocationResolver.getPath(repository); + return create(repository.getId(), path); } Path create(String repositoryId) { Path path = initialRepositoryLocationResolver.getPath(repositoryId); + return create(repositoryId, path); + } + + private Path create(String repositoryId, Path path) { + if (Files.exists(path)) { + throw new RepositoryStorageException("path " + path + " for repository " + repositoryId + " already exists"); + } setLocation(repositoryId, path); Path resolvedPath = contextProvider.resolve(path); try { fileSystem.create(resolvedPath.toFile()); } catch (Exception e) { - throw new InternalRepositoryException(entity("Repository", repositoryId), "could not create directory for new repository", e); + throw new RepositoryStorageException("could not create directory " + path + " for new repository " + repositoryId, e); } return resolvedPath; } @@ -195,4 +243,40 @@ public class PathBasedRepositoryLocationResolver extends BasicRepositoryLocation public void refresh() { this.read(); } + + void registerMaintenanceCallback(MaintenanceCallback maintenanceCallback) { + maintenanceCallbacks.addListener(maintenanceCallback); + } + + public interface MaintenanceCallback { + default void downForMaintenance(DownForMaintenanceContext context) {} + + default void upAfterMaintenance(UpAfterMaintenanceContext context) {} + } + + @Getter + @EqualsAndHashCode + public static class DownForMaintenanceContext { + private final String repositoryId; + + DownForMaintenanceContext(String repositoryId) { + this.repositoryId = repositoryId; + } + } + + @Getter + @EqualsAndHashCode + public static class UpAfterMaintenanceContext { + private final String repositoryId; + private final Path location; + + UpAfterMaintenanceContext(String repositoryId, Path location) { + this.repositoryId = repositoryId; + this.location = location; + } + } + + private interface Modifier { + void modify(Path oldPath) throws IOException; + } } 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 b9d69c7910..7df734cd8e 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 @@ -28,6 +28,7 @@ package sonia.scm.repository.xml; import com.google.common.collect.ImmutableList; import com.google.inject.Singleton; +import lombok.extern.slf4j.Slf4j; import sonia.scm.io.FileSystem; import sonia.scm.repository.InternalRepositoryException; import sonia.scm.repository.NamespaceAndName; @@ -35,6 +36,8 @@ import sonia.scm.repository.Repository; import sonia.scm.repository.RepositoryDAO; import sonia.scm.repository.RepositoryExportingCheck; import sonia.scm.repository.RepositoryLocationResolver; +import sonia.scm.repository.xml.PathBasedRepositoryLocationResolver.DownForMaintenanceContext; +import sonia.scm.repository.xml.PathBasedRepositoryLocationResolver.UpAfterMaintenanceContext; import sonia.scm.store.StoreReadOnlyException; import javax.inject.Inject; @@ -55,6 +58,7 @@ import java.util.stream.Collectors; * @author Sebastian Sdorra */ @Singleton +@Slf4j public class XmlRepositoryDAO implements RepositoryDAO { private final MetadataStore metadataStore = new MetadataStore(); @@ -77,15 +81,35 @@ public class XmlRepositoryDAO implements RepositoryDAO { this.byNamespaceAndName = new TreeMap<>(); init(); + + this.repositoryLocationResolver.registerMaintenanceCallback(new PathBasedRepositoryLocationResolver.MaintenanceCallback() { + @Override + public void downForMaintenance(DownForMaintenanceContext context) { + Repository repository = byId.get(context.getRepositoryId()); + byNamespaceAndName.remove(repository.getNamespaceAndName()); + byId.remove(context.getRepositoryId()); + } + + @Override + public void upAfterMaintenance(UpAfterMaintenanceContext context) { + Repository repository = metadataStore.read(context.getLocation()); + byNamespaceAndName.put(repository.getNamespaceAndName(), repository); + byId.put(context.getRepositoryId(), repository); + } + }); } private void init() { withWriteLockedMaps(() -> { RepositoryLocationResolver.RepositoryLocationResolverInstance pathRepositoryLocationResolverInstance = repositoryLocationResolver.create(Path.class); pathRepositoryLocationResolverInstance.forAllLocations((repositoryId, repositoryPath) -> { - Repository repository = metadataStore.read(repositoryPath); - byNamespaceAndName.put(repository.getNamespaceAndName(), repository); - byId.put(repositoryId, repository); + try { + Repository repository = metadataStore.read(repositoryPath); + byNamespaceAndName.put(repository.getNamespaceAndName(), repository); + byId.put(repositoryId, repository); + } catch (InternalRepositoryException e) { + log.error("could not read repository metadata from {}", repositoryPath, e); + } }); }); } @@ -97,7 +121,7 @@ public class XmlRepositoryDAO implements RepositoryDAO { @Override public synchronized void add(Repository repository) { - add(repository, repositoryLocationResolver.create(repository.getId())); + add(repository, repositoryLocationResolver.create(repository)); } public synchronized void add(Repository repository, Object location) { 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 7c474ea95c..9c27b0ff8f 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 @@ -31,6 +31,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.io.TempDir; import org.mockito.Mock; +import org.mockito.Spy; import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoSettings; import org.mockito.quality.Strictness; @@ -38,17 +39,25 @@ import sonia.scm.SCMContextProvider; import sonia.scm.io.DefaultFileSystem; import sonia.scm.io.FileSystem; import sonia.scm.repository.InitialRepositoryLocationResolver; +import sonia.scm.repository.RepositoryLocationResolver; +import sonia.scm.repository.RepositoryLocationResolver.RepositoryLocationResolverInstance; +import sonia.scm.repository.xml.PathBasedRepositoryLocationResolver.DownForMaintenanceContext; +import sonia.scm.repository.xml.PathBasedRepositoryLocationResolver.UpAfterMaintenanceContext; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.attribute.PosixFilePermission; import java.time.Clock; import java.util.HashMap; import java.util.Map; +import java.util.Set; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) @@ -90,6 +99,16 @@ class PathBasedRepositoryLocationResolverTest { assertThat(path).isDirectory(); } + @Test + void shouldFailIfDirectoryExists() throws IOException { + Files.createDirectories(basePath.resolve("newId")); + + RepositoryLocationResolverInstance resolverInstance = resolver.forClass(Path.class); + + assertThatThrownBy(() -> resolverInstance.createLocation("newId")) + .isInstanceOf(RepositoryLocationResolver.RepositoryStorageException.class); + } + @Test void shouldPersistInitialDirectory() { resolver.forClass(Path.class).createLocation("newId"); @@ -133,11 +152,15 @@ class PathBasedRepositoryLocationResolverTest { private PathBasedRepositoryLocationResolver resolverWithExistingData; + @Spy + private PathBasedRepositoryLocationResolver.MaintenanceCallback maintenanceCallback; + @BeforeEach void createExistingDatabase() { resolver.forClass(Path.class).createLocation("existingId_1"); resolver.forClass(Path.class).createLocation("existingId_2"); resolverWithExistingData = createResolver(); + resolverWithExistingData.registerMaintenanceCallback(maintenanceCallback); } @Test @@ -176,6 +199,51 @@ class PathBasedRepositoryLocationResolverTest { assertThat(path).doesNotExist(); } + + @Test + void shouldModifyLocation() throws IOException { + Path oldPath = resolverWithExistingData.create(Path.class).getLocation("existingId_1"); + Path newPath = basePath.resolve("modified_location"); + + resolverWithExistingData.create(Path.class).modifyLocation("existingId_1", newPath); + + assertThat(newPath).exists(); + assertThat(oldPath).doesNotExist(); + assertThat(resolverWithExistingData.create(Path.class).getLocation("existingId_1")).isEqualTo(newPath); + verify(maintenanceCallback).downForMaintenance(new DownForMaintenanceContext("existingId_1")); + verify(maintenanceCallback).upAfterMaintenance(new UpAfterMaintenanceContext("existingId_1", newPath)); + } + + @Test + void shouldModifyLocationAndKeepOld() throws IOException { + Path oldPath = resolverWithExistingData.create(Path.class).getLocation("existingId_1"); + Path newPath = basePath.resolve("modified_location"); + + resolverWithExistingData.create(Path.class).modifyLocationAndKeepOld("existingId_1", newPath); + + assertThat(newPath).exists(); + assertThat(oldPath).exists(); + assertThat(resolverWithExistingData.create(Path.class).getLocation("existingId_1")).isEqualTo(newPath); + verify(maintenanceCallback).downForMaintenance(new DownForMaintenanceContext("existingId_1")); + verify(maintenanceCallback).upAfterMaintenance(new UpAfterMaintenanceContext("existingId_1", newPath)); + } + + @Test + void shouldHandleErrorOnModifyLocation() throws IOException { + Path oldPath = resolverWithExistingData.create(Path.class).getLocation("existingId_1"); + Path newPath = basePath.resolve("thou").resolve("shall").resolve("not").resolve("move").resolve("here"); + Files.createDirectories(newPath); + Files.setPosixFilePermissions(newPath, Set.of(PosixFilePermission.OWNER_READ)); + + assertThatThrownBy(() -> resolverWithExistingData.create(Path.class).modifyLocationAndKeepOld("existingId_1", newPath)) + .isInstanceOf(RepositoryLocationResolver.RepositoryStorageException.class); + + assertThat(newPath).exists(); + assertThat(oldPath).exists(); + assertThat(resolverWithExistingData.create(Path.class).getLocation("existingId_1")).isEqualTo(oldPath); + verify(maintenanceCallback).downForMaintenance(new DownForMaintenanceContext("existingId_1")); + verify(maintenanceCallback).upAfterMaintenance(new UpAfterMaintenanceContext("existingId_1", oldPath)); + } } private String getXmlFileContent() { diff --git a/scm-dao-xml/src/test/java/sonia/scm/repository/xml/XmlRepositoryDAOSynchronizationTest.java b/scm-dao-xml/src/test/java/sonia/scm/repository/xml/XmlRepositoryDAOSynchronizationTest.java index bddee516d9..ce04a8b096 100644 --- a/scm-dao-xml/src/test/java/sonia/scm/repository/xml/XmlRepositoryDAOSynchronizationTest.java +++ b/scm-dao-xml/src/test/java/sonia/scm/repository/xml/XmlRepositoryDAOSynchronizationTest.java @@ -43,6 +43,7 @@ import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; +import static java.util.Collections.emptySet; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.when; @@ -75,7 +76,7 @@ class XmlRepositoryDAOSynchronizationTest { fileSystem = new DefaultFileSystem(); resolver = new PathBasedRepositoryLocationResolver( - provider, new InitialRepositoryLocationResolver(), fileSystem + provider, new InitialRepositoryLocationResolver(emptySet()), fileSystem ); repositoryDAO = new XmlRepositoryDAO(resolver, fileSystem, repositoryExportingCheck); diff --git a/scm-dao-xml/src/test/java/sonia/scm/repository/xml/XmlRepositoryDAOTest.java b/scm-dao-xml/src/test/java/sonia/scm/repository/xml/XmlRepositoryDAOTest.java index a388621e31..57d46f074e 100644 --- a/scm-dao-xml/src/test/java/sonia/scm/repository/xml/XmlRepositoryDAOTest.java +++ b/scm-dao-xml/src/test/java/sonia/scm/repository/xml/XmlRepositoryDAOTest.java @@ -32,8 +32,9 @@ import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.io.TempDir; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; import org.mockito.Mock; -import org.mockito.invocation.InvocationOnMock; import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoSettings; import org.mockito.quality.Strictness; @@ -44,6 +45,8 @@ import sonia.scm.repository.Repository; import sonia.scm.repository.RepositoryExportingCheck; import sonia.scm.repository.RepositoryLocationResolver; import sonia.scm.repository.RepositoryPermission; +import sonia.scm.repository.xml.PathBasedRepositoryLocationResolver.DownForMaintenanceContext; +import sonia.scm.repository.xml.PathBasedRepositoryLocationResolver.UpAfterMaintenanceContext; import sonia.scm.store.StoreReadOnlyException; import java.io.IOException; @@ -59,7 +62,9 @@ import static java.util.Collections.singletonList; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.fail; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -104,12 +109,13 @@ class XmlRepositoryDAOTest { } } ); - when(locationResolver.create(anyString())).thenAnswer(invocation -> createMockedRepoPath(basePath, invocation)); + when(locationResolver.create(any(Repository.class))).thenAnswer(invocation -> createMockedRepoPath(basePath, invocation.getArgument(0, Repository.class).getId())); + when(locationResolver.create(anyString())).thenAnswer(invocation -> createMockedRepoPath(basePath, invocation.getArgument(0, String.class))); when(locationResolver.remove(anyString())).thenAnswer(invocation -> basePath.resolve(invocation.getArgument(0).toString())); } - private Path createMockedRepoPath(@TempDir Path basePath, InvocationOnMock invocation) { - Path resolvedPath = basePath.resolve(invocation.getArgument(0).toString()); + private static Path createMockedRepoPath(Path basePath, String repositoryId) { + Path resolvedPath = basePath.resolve(repositoryId); try { Files.createDirectories(resolvedPath); } catch (IOException e) { @@ -378,6 +384,8 @@ class XmlRepositoryDAOTest { class WithExistingRepositories { private Path repositoryPath; + @Captor + private ArgumentCaptor callbackArgumentCaptor; @BeforeEach void createMetadataFileForRepository(@TempDir Path basePath) throws IOException { @@ -415,6 +423,26 @@ class XmlRepositoryDAOTest { assertThat(dao.contains(new NamespaceAndName("space", "existing"))).isTrue(); } + @Test + void shouldHandleMaintenanceEvents() { + doNothing().when(locationResolver).registerMaintenanceCallback(callbackArgumentCaptor.capture()); + mockExistingPath(); + + XmlRepositoryDAO dao = new XmlRepositoryDAO(locationResolver, fileSystem, repositoryExportingCheck); + + callbackArgumentCaptor.getValue().downForMaintenance(new DownForMaintenanceContext("existing")); + + assertThat(dao.contains("existing")).isFalse(); + assertThat(dao.contains(new NamespaceAndName("space", "existing"))).isFalse(); + assertThat(dao.getAll()).isEmpty(); + + callbackArgumentCaptor.getValue().upAfterMaintenance(new UpAfterMaintenanceContext("existing", repositoryPath)); + + assertThat(dao.contains("existing")).isTrue(); + assertThat(dao.contains(new NamespaceAndName("space", "existing"))).isTrue(); + assertThat(dao.getAll()).hasSize(1); + } + private void mockExistingPath() { triggeredOnForAllLocations = consumer -> consumer.accept("existing", repositoryPath); } diff --git a/scm-dao-xml/src/test/java/sonia/scm/store/JAXBPropertyFileAccessTest.java b/scm-dao-xml/src/test/java/sonia/scm/store/JAXBPropertyFileAccessTest.java index 6eafabdd95..0a9f9ae79a 100644 --- a/scm-dao-xml/src/test/java/sonia/scm/store/JAXBPropertyFileAccessTest.java +++ b/scm-dao-xml/src/test/java/sonia/scm/store/JAXBPropertyFileAccessTest.java @@ -44,6 +44,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; +import static java.util.Collections.emptySet; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.lenient; @@ -69,7 +70,7 @@ class JAXBPropertyFileAccessTest { lenient().when(contextProvider.getBaseDirectory()).thenReturn(tempDir.toFile()); lenient().when(contextProvider.resolve(any())).thenAnswer(invocation -> tempDir.resolve(invocation.getArgument(0).toString())); - locationResolver = new PathBasedRepositoryLocationResolver(contextProvider, new InitialRepositoryLocationResolver(), new DefaultFileSystem()); + locationResolver = new PathBasedRepositoryLocationResolver(contextProvider, new InitialRepositoryLocationResolver(emptySet()), new DefaultFileSystem()); fileAccess = new JAXBPropertyFileAccess(contextProvider, locationResolver); } diff --git a/scm-test/src/main/java/sonia/scm/AbstractTestBase.java b/scm-test/src/main/java/sonia/scm/AbstractTestBase.java index 4dfa974189..160c2b7841 100644 --- a/scm-test/src/main/java/sonia/scm/AbstractTestBase.java +++ b/scm-test/src/main/java/sonia/scm/AbstractTestBase.java @@ -38,9 +38,7 @@ import org.junit.Before; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; -import sonia.scm.io.DefaultFileSystem; import sonia.scm.repository.InitialRepositoryLocationResolver; -import sonia.scm.repository.RepositoryDAO; import sonia.scm.repository.RepositoryLocationResolver; import sonia.scm.util.IOUtil; import sonia.scm.util.MockUtil; @@ -50,8 +48,8 @@ import java.io.IOException; import java.util.UUID; import java.util.logging.Logger; +import static java.util.Collections.emptySet; import static org.junit.Assert.assertTrue; -import static org.mockito.Mockito.mock; /** * @@ -76,7 +74,7 @@ public class AbstractTestBase UUID.randomUUID().toString()); assertTrue(tempDirectory.mkdirs()); contextProvider = MockUtil.getSCMContextProvider(tempDirectory); - InitialRepositoryLocationResolver initialRepoLocationResolver = new InitialRepositoryLocationResolver(); + InitialRepositoryLocationResolver initialRepoLocationResolver = new InitialRepositoryLocationResolver(emptySet()); repositoryLocationResolver = new TempDirRepositoryLocationResolver(tempDirectory); postSetUp(); } diff --git a/scm-test/src/main/java/sonia/scm/ManagerTestBase.java b/scm-test/src/main/java/sonia/scm/ManagerTestBase.java index 88f29a93f3..b6b368250e 100644 --- a/scm-test/src/main/java/sonia/scm/ManagerTestBase.java +++ b/scm-test/src/main/java/sonia/scm/ManagerTestBase.java @@ -21,7 +21,7 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - + package sonia.scm; import org.junit.After; @@ -36,6 +36,7 @@ import sonia.scm.util.MockUtil; import java.io.File; import java.io.IOException; +import static java.util.Collections.emptySet; import static org.mockito.Mockito.mock; /** @@ -49,7 +50,7 @@ public abstract class ManagerTestBase @Rule public TemporaryFolder tempFolder = new TemporaryFolder(); - + protected SCMContextProvider contextProvider; protected RepositoryLocationResolver locationResolver; @@ -63,18 +64,18 @@ public abstract class ManagerTestBase temp = tempFolder.newFolder(); } contextProvider = MockUtil.getSCMContextProvider(temp); - InitialRepositoryLocationResolver initialRepositoryLocationResolver = new InitialRepositoryLocationResolver(); + InitialRepositoryLocationResolver initialRepositoryLocationResolver = new InitialRepositoryLocationResolver(emptySet()); RepositoryDAO repoDao = mock(RepositoryDAO.class); locationResolver = new TempDirRepositoryLocationResolver(temp); manager = createManager(); manager.init(contextProvider); } - + @After public void tearDown() throws IOException { manager.close(); } - + /** * Method description * diff --git a/scm-ui/ui-extensions/src/extensionPoints.tsx b/scm-ui/ui-extensions/src/extensionPoints.tsx index ddfc2d474a..7451376d76 100644 --- a/scm-ui/ui-extensions/src/extensionPoints.tsx +++ b/scm-ui/ui-extensions/src/extensionPoints.tsx @@ -667,6 +667,11 @@ export type RepositoryDeleteButton = RenderableExtensionPointDefinition< { repository: Repository } >; +export type RepositoryDangerZone = RenderableExtensionPointDefinition< + "repository.dangerZone", + { repository: Repository } +>; + export type RepositoryInformationTableBottom = RenderableExtensionPointDefinition< "repository.information.table.bottom", { repository: Repository } diff --git a/scm-ui/ui-webapp/src/repos/containers/RepositoryDangerZone.tsx b/scm-ui/ui-webapp/src/repos/containers/RepositoryDangerZone.tsx index 00d172caba..df611ba31c 100644 --- a/scm-ui/ui-webapp/src/repos/containers/RepositoryDangerZone.tsx +++ b/scm-ui/ui-webapp/src/repos/containers/RepositoryDangerZone.tsx @@ -57,6 +57,14 @@ const RepositoryDangerZone: FC = ({ repository }) => { if (repository?._links?.unarchive) { dangerZone.push(); } + dangerZone.push( + + name="repository.dangerZone" + props={{ repository }} + renderAll={true} + /> + ); + if (dangerZone.length === 0) { return null; } diff --git a/scm-webapp/src/main/java/sonia/scm/api/RepositoryStorageExceptionMapper.java b/scm-webapp/src/main/java/sonia/scm/api/RepositoryStorageExceptionMapper.java new file mode 100644 index 0000000000..076f0bf0ab --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/RepositoryStorageExceptionMapper.java @@ -0,0 +1,58 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.api; + +import lombok.extern.slf4j.Slf4j; +import org.slf4j.MDC; +import sonia.scm.api.v2.resources.ErrorDto; +import sonia.scm.repository.RepositoryLocationResolver; +import sonia.scm.web.VndMediaType; + +import javax.ws.rs.core.Response; +import javax.ws.rs.ext.ExceptionMapper; +import javax.ws.rs.ext.Provider; + +import static java.util.Arrays.asList; + +@Slf4j +@Provider +public class RepositoryStorageExceptionMapper implements ExceptionMapper { + @Override + public Response toResponse(RepositoryLocationResolver.RepositoryStorageException exception) { + log.error("exception in repository storage", exception); + ErrorDto error = new ErrorDto(); + error.setTransactionId(MDC.get("transaction_id")); + error.setMessage("could not store repository: " + exception.getMessage()); + error.setErrorCode("E4TrutUSv1"); + ErrorDto.ConstraintViolationDto violation = new ErrorDto.ConstraintViolationDto(); + violation.setPath("storage location"); + violation.setMessage(exception.getRootMessage()); + error.setViolations(asList(violation)); + return Response.status(Response.Status.BAD_REQUEST) + .entity(error) + .type(VndMediaType.ERROR_TYPE) + .build(); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/lifecycle/modules/BootstrapModule.java b/scm-webapp/src/main/java/sonia/scm/lifecycle/modules/BootstrapModule.java index f1b87f2253..d9f83b5503 100644 --- a/scm-webapp/src/main/java/sonia/scm/lifecycle/modules/BootstrapModule.java +++ b/scm-webapp/src/main/java/sonia/scm/lifecycle/modules/BootstrapModule.java @@ -46,6 +46,7 @@ import sonia.scm.repository.DefaultRepositoryExportingCheck; import sonia.scm.repository.EventDrivenRepositoryArchiveCheck; import sonia.scm.repository.RepositoryArchivedCheck; import sonia.scm.repository.RepositoryExportingCheck; +import sonia.scm.repository.RepositoryLocationOverride; import sonia.scm.repository.RepositoryLocationResolver; import sonia.scm.repository.xml.MetadataStore; import sonia.scm.repository.xml.PathBasedRepositoryLocationResolver; @@ -133,6 +134,7 @@ public class BootstrapModule extends AbstractModule { // bind metrics bind(MeterRegistry.class).toProvider(MeterRegistryProvider.class).asEagerSingleton(); Multibinder.newSetBinder(binder(), MonitoringSystem.class); + Multibinder.newSetBinder(binder(), RepositoryLocationOverride.class); // bind cache bind(CacheManager.class, GuavaCacheManager.class); diff --git a/scm-webapp/src/main/resources/locales/de/plugins.json b/scm-webapp/src/main/resources/locales/de/plugins.json index 2f55979e06..e8f5c96452 100644 --- a/scm-webapp/src/main/resources/locales/de/plugins.json +++ b/scm-webapp/src/main/resources/locales/de/plugins.json @@ -483,6 +483,10 @@ "5FSV2kreE1": { "summary": "'svn verify' fehlgeschlagen", "description": "Die Prüfung 'svn verify' ist für das Repository fehlgeschlagen." + }, + "E4TrutUSv1": { + "summary": "Speicherung des Repositories fehlgeschlagen", + "description": "Beim Speichern des Repositories ist ein Fehler aufgetreten. Weitere Hinweise finden sich im Server Log." } }, "namespaceStrategies": { diff --git a/scm-webapp/src/main/resources/locales/en/plugins.json b/scm-webapp/src/main/resources/locales/en/plugins.json index fdd1082472..1d558bd31f 100644 --- a/scm-webapp/src/main/resources/locales/en/plugins.json +++ b/scm-webapp/src/main/resources/locales/en/plugins.json @@ -424,6 +424,10 @@ "3ITWbABz91": { "displayName": "API keys disabled", "description": "The usage of API keys has been disabled in the global configuration. To use API keys, this has to be enabled again." + }, + "E4TrutUSv1": { + "displayName": "Failed to store the repository", + "description": "An error occurred while storing the repository. Further information can be found in the server log." } }, "healthChecksFailures": {