Add functionality to modify repository storage locations

The repository location resolver gets a new function
that allows to change the location of a repository.

Pushed-by: Rene Pfeuffer<rene.pfeuffer@cloudogu.com>
Co-authored-by: René Pfeuffer<rene.pfeuffer@cloudogu.com>
Committed-by: René Pfeuffer<rene.pfeuffer@cloudogu.com>
This commit is contained in:
Rene Pfeuffer
2023-10-13 10:23:29 +02:00
parent 0bfc5183cc
commit d0f8161220
23 changed files with 470 additions and 58 deletions

View File

@@ -0,0 +1,2 @@
- type: added
description: Internal API to modify repository storage locations

View File

@@ -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}",

View File

@@ -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<RepositoryLocationOverride> repositoryLocationOverrides;
@Inject
public InitialRepositoryLocationResolver(Set<RepositoryLocationOverride> 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;
}
}

View File

@@ -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);
}

View File

@@ -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<String, T> 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();
}
}
}
}

View File

@@ -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<T> {
/**
* 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);
}

View File

@@ -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");
}
}

View File

@@ -28,6 +28,9 @@ plugins {
}
dependencies {
implementation libraries.commonsIo
implementation libraries.commonsLang3
api platform(project(':'))
api project(':scm-core')

View File

@@ -54,12 +54,13 @@ public class MetadataStore implements UpdateStepRepositoryMetadataAccess<Path> {
}
}
@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<Path> {
}).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 {

View File

@@ -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.
* <p>
@@ -69,6 +73,8 @@ public class PathBasedRepositoryLocationResolver extends BasicRepositoryLocation
private long creationTime;
private long lastModified;
private EventListenerSupport<MaintenanceCallback> 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 <T> RepositoryLocationResolverInstance<T> create(Class<T> type) {
return new RepositoryLocationResolverInstance<T>() {
@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<T>) new RepositoryLocationResolverInstance<Path>() {
@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<String, T> 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<String, Path> 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;
}
}

View File

@@ -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<Path> 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) {

View File

@@ -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<Path> 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() {

View File

@@ -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);

View File

@@ -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<PathBasedRepositoryLocationResolver.MaintenanceCallback> 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);
}

View File

@@ -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);
}

View File

@@ -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();
}

View File

@@ -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<T extends ModelObject>
@Rule
public TemporaryFolder tempFolder = new TemporaryFolder();
protected SCMContextProvider contextProvider;
protected RepositoryLocationResolver locationResolver;
@@ -63,18 +64,18 @@ public abstract class ManagerTestBase<T extends ModelObject>
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
*

View File

@@ -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 }

View File

@@ -57,6 +57,14 @@ const RepositoryDangerZone: FC<Props> = ({ repository }) => {
if (repository?._links?.unarchive) {
dangerZone.push(<UnarchiveRepo repository={repository} />);
}
dangerZone.push(
<ExtensionPoint<extensionPoints.RepositoryDangerZone>
name="repository.dangerZone"
props={{ repository }}
renderAll={true}
/>
);
if (dangerZone.length === 0) {
return null;
}

View File

@@ -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<RepositoryLocationResolver.RepositoryStorageException> {
@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();
}
}

View File

@@ -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);

View File

@@ -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": {

View File

@@ -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": {