Repository export read-only lock (#1519)

* Lock repository for read-only access only while exporting
* Create read-only check api

Co-authored-by: René Pfeuffer <rene.pfeuffer@cloudogu.com>
This commit is contained in:
Eduard Heimbuch
2021-02-04 15:29:49 +01:00
committed by GitHub
parent 04c6243f64
commit ac5d145266
54 changed files with 1104 additions and 182 deletions

View File

@@ -18,6 +18,7 @@ Strategie `benutzerdefiniert` ausgewählt ist, kann zusätzlich zum Repository N
Ein archiviertes Repository kann nicht mehr verändert werden.
In dem Bereich "Repository exportieren" kann das Repository in unterschiedlichen Formaten exportiert werden.
Während des laufenden Exports kann auf das Repository nur lesend zugriffen werden.
Das Ausgabeformat des Repository kann über die angebotenen Optionen verändert werden:
* `Standard`: Werden keine Optionen ausgewählt, wird das Repository im Standard Format exportiert.
Git und Mercurial werden dabei als `Tar Archiv` exportiert und Subversion nutzt das `Dump` Format.

View File

@@ -16,6 +16,7 @@ strategy in the global SCM-Manager config is set to `custom` you may even rename
repository is marked as archived, it can no longer be modified.
In the "Export repository" section the repository can be exported in different formats.
During the export the repository cannot be modified!
The output format of the repository can be changed via the offered options:
* `Standard`: If no options are selected, the repository will be exported in the standard format.
Git and Mercurial are exported as `Tar archive` and Subversion uses the `Dump` format.

View File

@@ -0,0 +1,2 @@
- type: added
description: Lock repository to "read-only" access during export ([#1519](https://github.com/scm-manager/scm-manager/pull/1519))

View File

@@ -0,0 +1,70 @@
/*
* 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 org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Supplier;
/**
* Default implementation of {@link RepositoryExportingCheck}. This tracks the exporting status of repositories.
*/
public final class DefaultRepositoryExportingCheck implements RepositoryExportingCheck {
private static final Logger LOG = LoggerFactory.getLogger(DefaultRepositoryExportingCheck.class);
private static final Map<String, AtomicInteger> EXPORTING_REPOSITORIES = Collections.synchronizedMap(new HashMap<>());
public static boolean isRepositoryExporting(String repositoryId) {
return getLockCount(repositoryId).get() > 0;
}
@Override
public boolean isExporting(String repositoryId) {
return isRepositoryExporting(repositoryId);
}
@Override
public <T> T withExportingLock(Repository repository, Supplier<T> callback) {
try {
getLockCount(repository.getId()).incrementAndGet();
return callback.get();
} finally {
int lockCount = getLockCount(repository.getId()).decrementAndGet();
if (lockCount <= 0) {
LOG.warn("Got negative export lock count {} for repository {}", lockCount, repository);
EXPORTING_REPOSITORIES.remove(repository.getId());
}
}
}
private static AtomicInteger getLockCount(String repositoryId) {
return EXPORTING_REPOSITORIES.computeIfAbsent(repositoryId, r -> new AtomicInteger(0));
}
}

View File

@@ -25,15 +25,11 @@
package sonia.scm.repository;
import com.github.legman.Subscribe;
import sonia.scm.EagerSingleton;
import sonia.scm.plugin.Extension;
import java.util.Collection;
import java.util.Collections;
import java.util.HashSet;
@Extension
@EagerSingleton
/**
* Default implementation of {@link RepositoryArchivedCheck}. This tracks the archive status of repositories by using
* {@link RepositoryModificationEvent}s. The initial set of archived repositories is read by

View File

@@ -188,7 +188,9 @@ public class Repository extends BasicPropertiesAware implements ModelObject, Per
return name;
}
public String getNamespace() { return namespace; }
public String getNamespace() {
return namespace;
}
@XmlTransient
public NamespaceAndName getNamespaceAndName() {
@@ -267,7 +269,9 @@ public class Repository extends BasicPropertiesAware implements ModelObject, Per
this.lastModified = lastModified;
}
public void setNamespace(String namespace) { this.namespace = namespace; }
public void setNamespace(String namespace) {
this.namespace = namespace;
}
public void setName(String name) {
this.name = name;

View File

@@ -27,7 +27,7 @@ package sonia.scm.repository;
/**
* Implementations of this class can be used to check whether a repository is archived.
*
* @since 1.12.0
* @since 2.12.0
*/
public interface RepositoryArchivedCheck {

View File

@@ -22,10 +22,9 @@
* SOFTWARE.
*/
package sonia.scm.repository.api;
package sonia.scm.repository;
import sonia.scm.ExceptionWithContext;
import sonia.scm.repository.Repository;
import static java.lang.String.format;
import static sonia.scm.ContextEntry.ContextBuilder.entity;
@@ -34,7 +33,7 @@ public class RepositoryArchivedException extends ExceptionWithContext {
public static final String CODE = "3hSIlptme1";
protected RepositoryArchivedException(Repository repository) {
public RepositoryArchivedException(Repository repository) {
super(entity(repository).build(), format("Repository %s is marked as archived and must not be modified", repository));
}

View File

@@ -0,0 +1,62 @@
/*
* 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 java.util.function.Supplier;
/**
* Implementations of this class can be used to check whether a repository is currently being exported.
*
* @since 2.14.0
*/
public interface RepositoryExportingCheck {
/**
* Checks whether the repository with the given id is currently (that is, at this moment) being exported or not.
* @param repositoryId The id of the repository to check.
* @return <code>true</code> when the repository with the given id is currently being exported, <code>false</code>
* otherwise.
*/
boolean isExporting(String repositoryId);
/**
* Checks whether the given repository is currently (that is, at this moment) being exported or not. This checks the
* status on behalf of the id of the repository, not by the exporting flag provided by the repository itself.
* @param repository The repository to check.
* @return <code>true</code> when the given repository is currently being exported, <code>false</code> otherwise.
*/
default boolean isExporting(Repository repository) {
return isExporting(repository.getId());
}
/**
* Asserts that the given repository is marked as being exported during the execution of the given callback.
* @param repository The repository that will be marked as being exported.
* @param callback This callback will be executed.
* @param <T> The return type of the callback.
* @return The result of the callback.
*/
<T> T withExportingLock(Repository repository, Supplier<T> callback);
}

View File

@@ -0,0 +1,44 @@
/*
* 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.ExceptionWithContext;
import static java.lang.String.format;
import static sonia.scm.ContextEntry.ContextBuilder.entity;
public class RepositoryExportingException extends ExceptionWithContext {
public static final String CODE = "1mSNlpe1V1";
public RepositoryExportingException(Repository repository) {
super(entity(repository).build(), format("Repository %s is currently being exported and must not be modified", repository));
}
@Override
public String getCode() {
return CODE;
}
}

View File

@@ -34,6 +34,7 @@ import java.util.Collections;
import java.util.HashSet;
import java.util.function.BooleanSupplier;
import static sonia.scm.repository.DefaultRepositoryExportingCheck.isRepositoryExporting;
import static sonia.scm.repository.EventDrivenRepositoryArchiveCheck.isRepositoryArchived;
/**
@@ -64,11 +65,14 @@ public class RepositoryPermissionGuard implements PermissionGuard<Repository> {
if (isRepositoryArchived(id)) {
throw new AuthorizationException("repository is archived");
}
if (isRepositoryExporting(id)) {
throw new AuthorizationException("repository is exporting");
}
}
@Override
public boolean isPermitted(Subject subject, String id, BooleanSupplier delegate) {
return !isRepositoryArchived(id) && delegate.getAsBoolean();
return !isRepositoryArchived(id) && !isRepositoryExporting(id) && delegate.getAsBoolean();
}
}
}

View File

@@ -0,0 +1,86 @@
/*
* 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 javax.inject.Inject;
/**
* Checks, whether a repository has to be considered read only. Currently, this includes {@link RepositoryArchivedCheck}
* and {@link RepositoryExportingCheck}.
*
* @since 2.14.0
*/
public final class RepositoryReadOnlyChecker {
private final RepositoryArchivedCheck archivedCheck;
private final RepositoryExportingCheck exportingCheck;
@Inject
public RepositoryReadOnlyChecker(RepositoryArchivedCheck archivedCheck, RepositoryExportingCheck exportingCheck) {
this.archivedCheck = archivedCheck;
this.exportingCheck = exportingCheck;
}
/**
* Checks if the repository is read only.
* @param repository The repository to check.
* @return <code>true</code> if any check locks the repository to read only access.
*/
public boolean isReadOnly(Repository repository) {
return isReadOnly(repository.getId());
}
/**
* Checks if the repository for the given id is read only.
* @param repositoryId The id of the given repository to check.
* @return <code>true</code> if any check locks the repository to read only access.
*/
public boolean isReadOnly(String repositoryId) {
return archivedCheck.isArchived(repositoryId) || exportingCheck.isExporting(repositoryId);
}
/**
* Checks if the repository may be modified.
*
* @throws RepositoryArchivedException if the repository is archived
* @throws RepositoryExportingException if the repository is currently being exported
*/
public static void checkReadOnly(Repository repository) {
if (isArchived(repository)) {
throw new RepositoryArchivedException(repository);
}
if (isExporting(repository)) {
throw new RepositoryExportingException(repository);
}
}
private static boolean isExporting(Repository repository) {
return DefaultRepositoryExportingCheck.isRepositoryExporting(repository.getId());
}
private static boolean isArchived(Repository repository) {
return repository.isArchived() || EventDrivenRepositoryArchiveCheck.isRepositoryArchived(repository.getId());
}
}

View File

@@ -30,7 +30,9 @@ import com.google.common.io.ByteSink;
import com.google.common.io.Files;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import sonia.scm.repository.InternalRepositoryException;
import sonia.scm.repository.Repository;
import sonia.scm.repository.RepositoryExportingCheck;
import sonia.scm.repository.spi.BundleCommand;
import sonia.scm.repository.spi.BundleCommandRequest;
@@ -63,12 +65,13 @@ public final class BundleCommandBuilder {
/**
* Constructs a new {@link BundleCommandBuilder}.
*
* @param bundleCommand bundle command implementation
* @param repositoryExportingCheck
* @param repository repository
*/
BundleCommandBuilder(BundleCommand bundleCommand, Repository repository) {
BundleCommandBuilder(BundleCommand bundleCommand, RepositoryExportingCheck repositoryExportingCheck, Repository repository) {
this.bundleCommand = bundleCommand;
this.repositoryExportingCheck = repositoryExportingCheck;
this.repository = repository;
}
@@ -79,9 +82,8 @@ public final class BundleCommandBuilder {
*
* @param outputFile output file
* @return bundle response
* @throws IOException
*/
public BundleResponse bundle(File outputFile) throws IOException {
public BundleResponse bundle(File outputFile) {
checkArgument((outputFile != null) && !outputFile.exists(),
"file is null or exists already");
@@ -91,7 +93,7 @@ public final class BundleCommandBuilder {
logger.info("create bundle at {} for repository {}", outputFile,
repository.getId());
return bundleCommand.bundle(request);
return bundleWithExportingLock(request);
}
/**
@@ -99,16 +101,14 @@ public final class BundleCommandBuilder {
*
* @param outputStream output stream
* @return bundle response
* @throws IOException
*/
public BundleResponse bundle(OutputStream outputStream)
throws IOException {
public BundleResponse bundle(OutputStream outputStream) {
checkNotNull(outputStream, "output stream is required");
logger.info("bundle {} to output stream", repository);
return bundleCommand.bundle(
new BundleCommandRequest(asByteSink(outputStream)));
BundleCommandRequest request = new BundleCommandRequest(asByteSink(outputStream));
return bundleWithExportingLock(request);
}
/**
@@ -116,14 +116,23 @@ public final class BundleCommandBuilder {
*
* @param sink byte sink
* @return bundle response
* @throws IOException
*/
public BundleResponse bundle(ByteSink sink)
throws IOException {
public BundleResponse bundle(ByteSink sink) {
checkNotNull(sink, "byte sink is required");
logger.info("bundle {} to byte sink", sink);
return bundleCommand.bundle(new BundleCommandRequest(sink));
BundleCommandRequest request = new BundleCommandRequest(sink);
return bundleWithExportingLock(request);
}
private BundleResponse bundleWithExportingLock(BundleCommandRequest request) {
return repositoryExportingCheck.withExportingLock(repository, () -> {
try {
return bundleCommand.bundle(request);
} catch (IOException e) {
throw new InternalRepositoryException(repository, "Exception during bundle; does not necessarily indicate a problem with the repository", e);
}
});
}
/**
@@ -162,4 +171,6 @@ public final class BundleCommandBuilder {
* repository
*/
private final Repository repository;
private final RepositoryExportingCheck repositoryExportingCheck;
}

View File

@@ -31,7 +31,9 @@ import sonia.scm.repository.Changeset;
import sonia.scm.repository.Feature;
import sonia.scm.repository.PreProcessorUtil;
import sonia.scm.repository.Repository;
import sonia.scm.repository.RepositoryExportingCheck;
import sonia.scm.repository.RepositoryPermissions;
import sonia.scm.repository.RepositoryReadOnlyChecker;
import sonia.scm.repository.spi.RepositoryServiceProvider;
import sonia.scm.repository.work.WorkdirProvider;
import sonia.scm.security.Authentications;
@@ -93,15 +95,18 @@ public final class RepositoryService implements Closeable {
@Nullable
private final EMail eMail;
private final RepositoryExportingCheck repositoryExportingCheck;
/**
* Constructs a new {@link RepositoryService}. This constructor should only
* be called from the {@link RepositoryServiceFactory}.
*
* @param cacheManager cache manager
* @param provider implementation for {@link RepositoryServiceProvider}
* @param repository the repository
* @param workdirProvider provider for workdirs
* @param eMail utility to compute email addresses if missing
* @param repositoryExportingCheck
*/
RepositoryService(CacheManager cacheManager,
RepositoryServiceProvider provider,
@@ -109,7 +114,7 @@ public final class RepositoryService implements Closeable {
PreProcessorUtil preProcessorUtil,
@SuppressWarnings({"rawtypes", "java:S3740"}) Set<ScmProtocolProvider> protocolProviders,
WorkdirProvider workdirProvider,
@Nullable EMail eMail) {
@Nullable EMail eMail, RepositoryExportingCheck repositoryExportingCheck) {
this.cacheManager = cacheManager;
this.provider = provider;
this.repository = repository;
@@ -117,6 +122,7 @@ public final class RepositoryService implements Closeable {
this.protocolProviders = protocolProviders;
this.workdirProvider = workdirProvider;
this.eMail = eMail;
this.repositoryExportingCheck = repositoryExportingCheck;
}
/**
@@ -182,7 +188,7 @@ public final class RepositoryService implements Closeable {
* by the implementation of the repository service provider.
*/
public BranchCommandBuilder getBranchCommand() {
verifyNotArchived();
RepositoryReadOnlyChecker.checkReadOnly(getRepository());
RepositoryPermissions.push(getRepository()).check();
LOG.debug("create branch command for repository {}",
repository.getNamespaceAndName());
@@ -217,7 +223,7 @@ public final class RepositoryService implements Closeable {
LOG.debug("create bundle command for repository {}",
repository.getNamespaceAndName());
return new BundleCommandBuilder(provider.getBundleCommand(), repository);
return new BundleCommandBuilder(provider.getBundleCommand(), repositoryExportingCheck, repository);
}
/**
@@ -333,7 +339,7 @@ public final class RepositoryService implements Closeable {
* @since 1.31
*/
public PullCommandBuilder getPullCommand() {
verifyNotArchived();
RepositoryReadOnlyChecker.checkReadOnly(getRepository());
LOG.debug("create pull command for repository {}",
repository.getNamespaceAndName());
@@ -383,12 +389,11 @@ public final class RepositoryService implements Closeable {
* The tag command allows the management of repository tags.
*
* @return instance of {@link TagCommandBuilder}
*
* @throws CommandNotSupportedException if the command is not supported
* by the implementation of the repository service provider.
*/
public TagCommandBuilder getTagCommand() {
verifyNotArchived();
RepositoryReadOnlyChecker.checkReadOnly(getRepository());
return new TagCommandBuilder(provider.getTagCommand());
}
@@ -418,7 +423,7 @@ public final class RepositoryService implements Closeable {
* @since 2.0.0
*/
public MergeCommandBuilder getMergeCommand() {
verifyNotArchived();
RepositoryReadOnlyChecker.checkReadOnly(getRepository());
LOG.debug("create merge command for repository {}",
repository.getNamespaceAndName());
@@ -440,7 +445,7 @@ public final class RepositoryService implements Closeable {
* @since 2.0.0
*/
public ModifyCommandBuilder getModifyCommand() {
verifyNotArchived();
RepositoryReadOnlyChecker.checkReadOnly(getRepository());
LOG.debug("create modify command for repository {}",
repository.getNamespaceAndName());
@@ -489,12 +494,6 @@ public final class RepositoryService implements Closeable {
.filter(protocol -> !Authentications.isAuthenticatedSubjectAnonymous() || protocol.isAnonymousEnabled());
}
private void verifyNotArchived() {
if (getRepository().isArchived()) {
throw new RepositoryArchivedException(getRepository());
}
}
@SuppressWarnings({"rawtypes", "java:S3740"})
private ScmProtocol createProviderInstanceForRepository(ScmProtocolProvider protocolProvider) {
return protocolProvider.get(repository);

View File

@@ -43,12 +43,14 @@ import sonia.scm.cache.CacheManager;
import sonia.scm.config.ScmConfiguration;
import sonia.scm.event.ScmEventBus;
import sonia.scm.repository.ClearRepositoryCacheEvent;
import sonia.scm.repository.DefaultRepositoryExportingCheck;
import sonia.scm.repository.NamespaceAndName;
import sonia.scm.repository.PostReceiveRepositoryHookEvent;
import sonia.scm.repository.PreProcessorUtil;
import sonia.scm.repository.Repository;
import sonia.scm.repository.RepositoryCacheKeyPredicate;
import sonia.scm.repository.RepositoryEvent;
import sonia.scm.repository.RepositoryExportingCheck;
import sonia.scm.repository.RepositoryManager;
import sonia.scm.repository.RepositoryPermissions;
import sonia.scm.repository.spi.RepositoryServiceProvider;
@@ -123,6 +125,7 @@ public final class RepositoryServiceFactory {
@SuppressWarnings({"rawtypes", "java:S3740"})
private final Set<ScmProtocolProvider> protocolProviders;
private final WorkdirProvider workdirProvider;
private final RepositoryExportingCheck repositoryExportingCheck;
@Nullable
private final EMail eMail;
@@ -141,7 +144,7 @@ public final class RepositoryServiceFactory {
* @param protocolProviders providers for repository protocols
* @param workdirProvider provider for working directories
*
* @deprecated use {@link RepositoryServiceFactory#RepositoryServiceFactory(CacheManager, RepositoryManager, Set, PreProcessorUtil, Set, WorkdirProvider, EMail)} instead
* @deprecated use {@link RepositoryServiceFactory#RepositoryServiceFactory(CacheManager, RepositoryManager, Set, PreProcessorUtil, Set, WorkdirProvider, EMail, RepositoryExportingCheck)} instead
* @since 1.21
*/
@Deprecated
@@ -152,7 +155,8 @@ public final class RepositoryServiceFactory {
WorkdirProvider workdirProvider) {
this(
cacheManager, repositoryManager, resolvers,
preProcessorUtil, protocolProviders, workdirProvider, null, ScmEventBus.getInstance()
preProcessorUtil, protocolProviders, workdirProvider, null, ScmEventBus.getInstance(),
new DefaultRepositoryExportingCheck()
);
}
@@ -174,11 +178,12 @@ public final class RepositoryServiceFactory {
public RepositoryServiceFactory(CacheManager cacheManager, RepositoryManager repositoryManager,
Set<RepositoryServiceResolver> resolvers, PreProcessorUtil preProcessorUtil,
@SuppressWarnings({"rawtypes", "java:S3740"}) Set<ScmProtocolProvider> protocolProviders,
WorkdirProvider workdirProvider, EMail eMail) {
WorkdirProvider workdirProvider, EMail eMail,
RepositoryExportingCheck repositoryExportingCheck) {
this(
cacheManager, repositoryManager, resolvers,
preProcessorUtil, protocolProviders, workdirProvider,
eMail, ScmEventBus.getInstance()
eMail, ScmEventBus.getInstance(), repositoryExportingCheck
);
}
@@ -187,7 +192,8 @@ public final class RepositoryServiceFactory {
RepositoryServiceFactory(CacheManager cacheManager, RepositoryManager repositoryManager,
Set<RepositoryServiceResolver> resolvers, PreProcessorUtil preProcessorUtil,
@SuppressWarnings({"rawtypes", "java:S3740"}) Set<ScmProtocolProvider> protocolProviders,
WorkdirProvider workdirProvider, @Nullable EMail eMail, ScmEventBus eventBus) {
WorkdirProvider workdirProvider, @Nullable EMail eMail, ScmEventBus eventBus,
RepositoryExportingCheck repositoryExportingCheck) {
this.cacheManager = cacheManager;
this.repositoryManager = repositoryManager;
this.resolvers = resolvers;
@@ -195,6 +201,7 @@ public final class RepositoryServiceFactory {
this.protocolProviders = protocolProviders;
this.workdirProvider = workdirProvider;
this.eMail = eMail;
this.repositoryExportingCheck = repositoryExportingCheck;
eventBus.register(new CacheClearHook(cacheManager));
}
@@ -284,7 +291,7 @@ public final class RepositoryServiceFactory {
}
service = new RepositoryService(cacheManager, provider, repository,
preProcessorUtil, protocolProviders, workdirProvider, eMail);
preProcessorUtil, protocolProviders, workdirProvider, eMail, repositoryExportingCheck);
break;
}

View File

@@ -0,0 +1,67 @@
/*
* 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 org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.AssertionsForClassTypes.assertThat;
class DefaultRepositoryExportingCheckTest {
private static final Repository EXPORTING_REPOSITORY = new Repository("exporting_hog", "git", "hitchhiker", "hog");
private final DefaultRepositoryExportingCheck check = new DefaultRepositoryExportingCheck();
@Test
void shouldBeReadOnlyIfBeingExported() {
check.withExportingLock(EXPORTING_REPOSITORY, () -> {
boolean readOnly = check.isExporting(EXPORTING_REPOSITORY);
assertThat(readOnly).isTrue();
return null;
});
}
@Test
void shouldBeReadOnlyIfBeingExportedMultipleTimes() {
check.withExportingLock(EXPORTING_REPOSITORY, () -> {
check.withExportingLock(EXPORTING_REPOSITORY, () -> {
boolean readOnly = check.isExporting(EXPORTING_REPOSITORY);
assertThat(readOnly).isTrue();
return null;
});
boolean readOnly = check.isExporting(EXPORTING_REPOSITORY);
assertThat(readOnly).isTrue();
return null;
});
}
@Test
void shouldNotBeReadOnlyIfNotBeingExported() {
boolean readOnly = check.isExporting(EXPORTING_REPOSITORY);
assertThat(readOnly).isFalse();
}
}

View File

@@ -38,9 +38,10 @@ import static sonia.scm.repository.EventDrivenRepositoryArchiveCheck.setAsArchiv
class EventDrivenRepositoryArchiveCheckTest {
private static final Repository NORMAL_REPOSITORY = new Repository("hog", "git", "hitchhiker", "hog");
private static final Repository ARCHIVED_REPOSITORY = new Repository("hog", "git", "hitchhiker", "hog");
private static final Repository ARCHIVED_REPOSITORY = new Repository("archived_hog", "git", "hitchhiker", "hog");
static {
ARCHIVED_REPOSITORY.setArchived(true);
EventDrivenRepositoryArchiveCheck.setAsArchived(ARCHIVED_REPOSITORY.getId());
}
EventDrivenRepositoryArchiveCheck check = new EventDrivenRepositoryArchiveCheck();
@@ -53,7 +54,7 @@ class EventDrivenRepositoryArchiveCheckTest {
@Test
void shouldBeArchivedAfterFlagHasBeenSet() {
check.updateListener(new RepositoryModificationEvent(HandlerEventType.MODIFY, ARCHIVED_REPOSITORY, NORMAL_REPOSITORY));
assertThat(check.isArchived("hog")).isTrue();
assertThat(check.isArchived("archived_hog")).isTrue();
}
@Test
@@ -70,6 +71,18 @@ class EventDrivenRepositoryArchiveCheckTest {
new EventDrivenRepositoryArchiveCheckInitializer(repositoryDAO).init(null);
assertThat(check.isArchived("hog")).isTrue();
assertThat(check.isArchived("archived_hog")).isTrue();
}
@Test
void shouldBeReadOnly() {
boolean readOnly = check.isArchived(ARCHIVED_REPOSITORY);
assertThat(readOnly).isTrue();
}
@Test
void shouldNotBeReadOnly() {
boolean readOnly = check.isArchived(NORMAL_REPOSITORY);
assertThat(readOnly).isFalse();
}
}

View File

@@ -33,12 +33,16 @@ 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.junit.jupiter.api.extension.ExtensionContext;
import org.junit.jupiter.api.extension.InvocationInterceptor;
import org.junit.jupiter.api.extension.ReflectiveInvocationContext;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.lang.reflect.Method;
import java.util.function.BooleanSupplier;
import static java.util.Arrays.asList;
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.mockito.Mockito.doThrow;
@@ -58,7 +62,7 @@ class RepositoryPermissionGuardTest {
@BeforeAll
static void setReadOnlyVerbs() {
RepositoryPermissionGuard.setReadOnlyVerbs(asList("read"));
RepositoryPermissionGuard.setReadOnlyVerbs(singletonList("read"));
}
@Nested
@@ -142,5 +146,47 @@ class RepositoryPermissionGuardTest {
verify(checkDelegate).run();
}
}
@Nested
@ExtendWith(WrapInExportCheck.class)
class WithExportingRepository {
@Test
void shouldInterceptPermissionCheck() {
assertThat(readInterceptor.isPermitted(subject, "1", permittedDelegate)).isFalse();
verify(permittedDelegate, never()).getAsBoolean();
}
@Test
void shouldInterceptCheckRequest() {
assertThrows(AuthorizationException.class, () -> readInterceptor.check(subject, "1", checkDelegate));
}
@Test
void shouldThrowConcretePermissionExceptionOverArchiveException() {
doThrow(new AuthorizationException()).when(checkDelegate).run();
assertThrows(AuthorizationException.class, () -> readInterceptor.check(subject, "1", checkDelegate));
verify(checkDelegate).run();
}
}
}
private static class WrapInExportCheck implements InvocationInterceptor {
public void interceptTestMethod(Invocation<Void> invocation,
ReflectiveInvocationContext<Method> invocationContext,
ExtensionContext extensionContext) {
new DefaultRepositoryExportingCheck().withExportingLock(new Repository("1", "git", "space", "X"), () -> {
try {
invocation.proceed();
return null;
} catch (Throwable t) {
throw new RuntimeException(t);
}
});
}
}
}

View File

@@ -0,0 +1,79 @@
/*
* 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 org.junit.jupiter.api.Test;
import java.util.function.Supplier;
import static org.assertj.core.api.Assertions.assertThat;
class RepositoryReadOnlyCheckerTest {
private final Repository repository = new Repository("1", "git","hitchhiker", "HeartOfGold");
private boolean archived = false;
private boolean exporting = false;
private final RepositoryArchivedCheck archivedCheck = repositoryId -> archived;
private final RepositoryExportingCheck exportingCheck = new RepositoryExportingCheck() {
@Override
public boolean isExporting(String repositoryId) {
return exporting;
}
@Override
public <T> T withExportingLock(Repository repository, Supplier<T> callback) {
return null;
}
};
private final RepositoryReadOnlyChecker checker = new RepositoryReadOnlyChecker(archivedCheck, exportingCheck);
@Test
void shouldReturnFalseIfAllChecksFalse() {
boolean readOnly = checker.isReadOnly(repository);
assertThat(readOnly).isFalse();
}
@Test
void shouldReturnTrueIfArchivedIsTrue() {
archived = true;
boolean readOnly = checker.isReadOnly(repository);
assertThat(readOnly).isTrue();
}
@Test
void shouldReturnTrueIfExportingIsTrue() {
exporting = true;
boolean readOnly = checker.isReadOnly(repository);
assertThat(readOnly).isTrue();
}
}

View File

@@ -39,6 +39,7 @@ import sonia.scm.NotFoundException;
import sonia.scm.cache.CacheManager;
import sonia.scm.config.ScmConfiguration;
import sonia.scm.event.ScmEventBus;
import sonia.scm.repository.DefaultRepositoryExportingCheck;
import sonia.scm.repository.NamespaceAndName;
import sonia.scm.repository.PreProcessorUtil;
import sonia.scm.repository.Repository;
@@ -94,7 +95,8 @@ class RepositoryServiceFactoryTest {
return new RepositoryServiceFactory(
cacheManager, repositoryManager, builder.build(),
preProcessorUtil, ImmutableSet.of(), workdirProvider,
new EMail(new ScmConfiguration()), eventBus
new EMail(new ScmConfiguration()), eventBus,
new DefaultRepositoryExportingCheck()
);
}

View File

@@ -34,7 +34,10 @@ import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import sonia.scm.SCMContext;
import sonia.scm.config.ScmConfiguration;
import sonia.scm.repository.DefaultRepositoryExportingCheck;
import sonia.scm.repository.Repository;
import sonia.scm.repository.RepositoryArchivedException;
import sonia.scm.repository.RepositoryExportingException;
import sonia.scm.repository.spi.HttpScmProtocol;
import sonia.scm.repository.spi.RepositoryServiceProvider;
import sonia.scm.user.EMail;
@@ -76,7 +79,7 @@ class RepositoryServiceTest {
@Test
void shouldReturnMatchingProtocolsFromProvider() {
when(subject.getPrincipal()).thenReturn("Hitchhiker");
RepositoryService repositoryService = new RepositoryService(null, provider, repository, null, Collections.singleton(new DummyScmProtocolProvider()), null, eMail);
RepositoryService repositoryService = new RepositoryService(null, provider, repository, null, Collections.singleton(new DummyScmProtocolProvider()), null, eMail, null);
Stream<ScmProtocol> supportedProtocols = repositoryService.getSupportedProtocols();
assertThat(sizeOf(supportedProtocols.collect(Collectors.toList()))).isEqualTo(1);
@@ -85,7 +88,7 @@ class RepositoryServiceTest {
@Test
void shouldFilterOutNonAnonymousEnabledProtocolsForAnonymousUser() {
when(subject.getPrincipal()).thenReturn(SCMContext.USER_ANONYMOUS);
RepositoryService repositoryService = new RepositoryService(null, provider, repository, null, Stream.of(new DummyScmProtocolProvider(), new DummyScmProtocolProvider(false)).collect(Collectors.toSet()), null, eMail);
RepositoryService repositoryService = new RepositoryService(null, provider, repository, null, Stream.of(new DummyScmProtocolProvider(), new DummyScmProtocolProvider(false)).collect(Collectors.toSet()), null, eMail, null);
Stream<ScmProtocol> supportedProtocols = repositoryService.getSupportedProtocols();
assertThat(sizeOf(supportedProtocols.collect(Collectors.toList()))).isEqualTo(1);
@@ -94,7 +97,7 @@ class RepositoryServiceTest {
@Test
void shouldFindKnownProtocol() {
when(subject.getPrincipal()).thenReturn("Hitchhiker");
RepositoryService repositoryService = new RepositoryService(null, provider, repository, null, Collections.singleton(new DummyScmProtocolProvider()), null, eMail);
RepositoryService repositoryService = new RepositoryService(null, provider, repository, null, Collections.singleton(new DummyScmProtocolProvider()), null, eMail, null);
HttpScmProtocol protocol = repositoryService.getProtocol(HttpScmProtocol.class);
@@ -104,7 +107,7 @@ class RepositoryServiceTest {
@Test
void shouldFailForUnknownProtocol() {
when(subject.getPrincipal()).thenReturn("Hitchhiker");
RepositoryService repositoryService = new RepositoryService(null, provider, repository, null, Collections.singleton(new DummyScmProtocolProvider()), null, eMail);
RepositoryService repositoryService = new RepositoryService(null, provider, repository, null, Collections.singleton(new DummyScmProtocolProvider()), null, eMail, null);
assertThrows(IllegalArgumentException.class, () -> repositoryService.getProtocol(UnknownScmProtocol.class));
}
@@ -112,14 +115,29 @@ class RepositoryServiceTest {
@Test
void shouldFailForArchivedRepository() {
repository.setArchived(true);
RepositoryService repositoryService = new RepositoryService(null, provider, repository, null, Collections.singleton(new DummyScmProtocolProvider()), null, eMail);
RepositoryService repositoryService = new RepositoryService(null, provider, repository, null, Collections.singleton(new DummyScmProtocolProvider()), null, eMail, null);
assertThrows(RepositoryArchivedException.class, () -> repositoryService.getModifyCommand());
assertThrows(RepositoryArchivedException.class, () -> repositoryService.getBranchCommand());
assertThrows(RepositoryArchivedException.class, () -> repositoryService.getPullCommand());
assertThrows(RepositoryArchivedException.class, () -> repositoryService.getTagCommand());
assertThrows(RepositoryArchivedException.class, () -> repositoryService.getMergeCommand());
assertThrows(RepositoryArchivedException.class, () -> repositoryService.getModifyCommand());
assertThrows(RepositoryArchivedException.class, repositoryService::getModifyCommand);
assertThrows(RepositoryArchivedException.class, repositoryService::getBranchCommand);
assertThrows(RepositoryArchivedException.class, repositoryService::getPullCommand);
assertThrows(RepositoryArchivedException.class, repositoryService::getTagCommand);
assertThrows(RepositoryArchivedException.class, repositoryService::getMergeCommand);
assertThrows(RepositoryArchivedException.class, repositoryService::getModifyCommand);
}
@Test
void shouldFailForExportingRepository() {
new DefaultRepositoryExportingCheck().withExportingLock(repository, () -> {
RepositoryService repositoryService = new RepositoryService(null, provider, repository, null, Collections.singleton(new DummyScmProtocolProvider()), null, eMail, null);
assertThrows(RepositoryExportingException.class, repositoryService::getModifyCommand);
assertThrows(RepositoryExportingException.class, repositoryService::getBranchCommand);
assertThrows(RepositoryExportingException.class, repositoryService::getPullCommand);
assertThrows(RepositoryExportingException.class, repositoryService::getTagCommand);
assertThrows(RepositoryExportingException.class, repositoryService::getMergeCommand);
assertThrows(RepositoryExportingException.class, repositoryService::getModifyCommand);
return null;
});
}
private static class DummyHttpProtocol extends HttpScmProtocol {
@@ -141,7 +159,7 @@ class RepositoryServiceTest {
}
}
private static class DummyScmProtocolProvider implements ScmProtocolProvider {
private static class DummyScmProtocolProvider implements ScmProtocolProvider<ScmProtocol> {
private final boolean anonymousEnabled;

View File

@@ -33,6 +33,7 @@ import sonia.scm.repository.InternalRepositoryException;
import sonia.scm.repository.NamespaceAndName;
import sonia.scm.repository.Repository;
import sonia.scm.repository.RepositoryDAO;
import sonia.scm.repository.RepositoryExportingCheck;
import sonia.scm.repository.RepositoryLocationResolver;
import sonia.scm.store.StoreReadOnlyException;
@@ -54,14 +55,16 @@ public class XmlRepositoryDAO implements RepositoryDAO {
private final PathBasedRepositoryLocationResolver repositoryLocationResolver;
private final FileSystem fileSystem;
private final RepositoryExportingCheck repositoryExportingCheck;
private final Map<String, Repository> byId;
private final Map<NamespaceAndName, Repository> byNamespaceAndName;
@Inject
public XmlRepositoryDAO(PathBasedRepositoryLocationResolver repositoryLocationResolver, FileSystem fileSystem) {
public XmlRepositoryDAO(PathBasedRepositoryLocationResolver repositoryLocationResolver, FileSystem fileSystem, RepositoryExportingCheck repositoryExportingCheck) {
this.repositoryLocationResolver = repositoryLocationResolver;
this.fileSystem = fileSystem;
this.repositoryExportingCheck = repositoryExportingCheck;
this.byId = new ConcurrentHashMap<>();
this.byNamespaceAndName = new ConcurrentHashMap<>();
@@ -140,7 +143,7 @@ public class XmlRepositoryDAO implements RepositoryDAO {
@Override
public void modify(Repository repository) {
Repository clone = repository.clone();
if (clone.isArchived() && byId.get(clone.getId()).isArchived()) {
if (mustNotModifyRepository(clone)) {
throw new StoreReadOnlyException(repository);
}
@@ -160,9 +163,14 @@ public class XmlRepositoryDAO implements RepositoryDAO {
metadataStore.write(repositoryPath, clone);
}
private boolean mustNotModifyRepository(Repository clone) {
return clone.isArchived() && byId.get(clone.getId()).isArchived()
|| repositoryExportingCheck.isExporting(clone);
}
@Override
public void delete(Repository repository) {
if (repository.isArchived()) {
if (repository.isArchived() || repositoryExportingCheck.isExporting(repository)) {
throw new StoreReadOnlyException(repository);
}
Path path;

View File

@@ -29,8 +29,8 @@ package sonia.scm.store;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import sonia.scm.SCMContextProvider;
import sonia.scm.repository.RepositoryArchivedCheck;
import sonia.scm.repository.RepositoryLocationResolver;
import sonia.scm.repository.RepositoryReadOnlyChecker;
import sonia.scm.util.IOUtil;
import java.io.File;
@@ -52,13 +52,13 @@ public abstract class FileBasedStoreFactory {
private final SCMContextProvider contextProvider;
private final RepositoryLocationResolver repositoryLocationResolver;
private final Store store;
private final RepositoryArchivedCheck archivedCheck;
private final RepositoryReadOnlyChecker readOnlyChecker;
protected FileBasedStoreFactory(SCMContextProvider contextProvider, RepositoryLocationResolver repositoryLocationResolver, Store store, RepositoryArchivedCheck archivedCheck) {
protected FileBasedStoreFactory(SCMContextProvider contextProvider, RepositoryLocationResolver repositoryLocationResolver, Store store, RepositoryReadOnlyChecker readOnlyChecker) {
this.contextProvider = contextProvider;
this.repositoryLocationResolver = repositoryLocationResolver;
this.store = store;
this.archivedCheck = archivedCheck;
this.readOnlyChecker = readOnlyChecker;
}
protected File getStoreLocation(StoreParameters storeParameters) {
@@ -83,11 +83,12 @@ public abstract class FileBasedStoreFactory {
}
protected boolean mustBeReadOnly(StoreParameters storeParameters) {
return storeParameters.getRepositoryId() != null && archivedCheck.isArchived(storeParameters.getRepositoryId());
return storeParameters.getRepositoryId() != null && readOnlyChecker.isReadOnly(storeParameters.getRepositoryId());
}
/**
* Get the store directory of a specific repository
*
* @param store the type of the store
* @param repositoryId the id of the repossitory
* @return the store directory of a specific repository
@@ -98,6 +99,7 @@ public abstract class FileBasedStoreFactory {
/**
* Get the global store directory
*
* @param store the type of the store
* @return the global store directory
*/

View File

@@ -28,11 +28,9 @@ package sonia.scm.store;
import com.google.inject.Inject;
import com.google.inject.Singleton;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import sonia.scm.SCMContextProvider;
import sonia.scm.repository.RepositoryArchivedCheck;
import sonia.scm.repository.RepositoryLocationResolver;
import sonia.scm.repository.RepositoryReadOnlyChecker;
import sonia.scm.security.KeyGenerator;
import sonia.scm.util.IOUtil;
@@ -46,11 +44,6 @@ import java.io.File;
@Singleton
public class FileBlobStoreFactory extends FileBasedStoreFactory implements BlobStoreFactory {
/**
* the logger for FileBlobStoreFactory
*/
private static final Logger LOG = LoggerFactory.getLogger(FileBlobStoreFactory.class);
private final KeyGenerator keyGenerator;
/**
@@ -60,8 +53,8 @@ public class FileBlobStoreFactory extends FileBasedStoreFactory implements BlobS
* @param keyGenerator key generator
*/
@Inject
public FileBlobStoreFactory(SCMContextProvider contextProvider , RepositoryLocationResolver repositoryLocationResolver, KeyGenerator keyGenerator, RepositoryArchivedCheck archivedCheck) {
super(contextProvider, repositoryLocationResolver, Store.BLOB, archivedCheck);
public FileBlobStoreFactory(SCMContextProvider contextProvider , RepositoryLocationResolver repositoryLocationResolver, KeyGenerator keyGenerator, RepositoryReadOnlyChecker readOnlyChecker) {
super(contextProvider, repositoryLocationResolver, Store.BLOB, readOnlyChecker);
this.keyGenerator = keyGenerator;
}

View File

@@ -29,8 +29,8 @@ package sonia.scm.store;
import com.google.inject.Inject;
import com.google.inject.Singleton;
import sonia.scm.SCMContextProvider;
import sonia.scm.repository.RepositoryArchivedCheck;
import sonia.scm.repository.RepositoryLocationResolver;
import sonia.scm.repository.RepositoryReadOnlyChecker;
import sonia.scm.security.KeyGenerator;
//~--- JDK imports ------------------------------------------------------------
@@ -46,8 +46,8 @@ public class JAXBConfigurationEntryStoreFactory extends FileBasedStoreFactory
private KeyGenerator keyGenerator;
@Inject
public JAXBConfigurationEntryStoreFactory(SCMContextProvider contextProvider, RepositoryLocationResolver repositoryLocationResolver, KeyGenerator keyGenerator, RepositoryArchivedCheck archivedCheck) {
super(contextProvider, repositoryLocationResolver, Store.CONFIG, archivedCheck);
public JAXBConfigurationEntryStoreFactory(SCMContextProvider contextProvider, RepositoryLocationResolver repositoryLocationResolver, KeyGenerator keyGenerator, RepositoryReadOnlyChecker readOnlyChecker) {
super(contextProvider, repositoryLocationResolver, Store.CONFIG, readOnlyChecker);
this.keyGenerator = keyGenerator;
}

View File

@@ -27,8 +27,8 @@ package sonia.scm.store;
import com.google.inject.Inject;
import com.google.inject.Singleton;
import sonia.scm.SCMContextProvider;
import sonia.scm.repository.RepositoryArchivedCheck;
import sonia.scm.repository.RepositoryLocationResolver;
import sonia.scm.repository.RepositoryReadOnlyChecker;
/**
* JAXB implementation of {@link ConfigurationStoreFactory}.
@@ -44,8 +44,8 @@ public class JAXBConfigurationStoreFactory extends FileBasedStoreFactory impleme
* @param repositoryLocationResolver Resolver to get the repository Directory
*/
@Inject
public JAXBConfigurationStoreFactory(SCMContextProvider contextProvider, RepositoryLocationResolver repositoryLocationResolver, RepositoryArchivedCheck archivedCheck) {
super(contextProvider, repositoryLocationResolver, Store.CONFIG, archivedCheck);
public JAXBConfigurationStoreFactory(SCMContextProvider contextProvider, RepositoryLocationResolver repositoryLocationResolver, RepositoryReadOnlyChecker readOnlyChecker) {
super(contextProvider, repositoryLocationResolver, Store.CONFIG, readOnlyChecker);
}
@Override

View File

@@ -29,8 +29,8 @@ package sonia.scm.store;
import com.google.inject.Inject;
import com.google.inject.Singleton;
import sonia.scm.SCMContextProvider;
import sonia.scm.repository.RepositoryArchivedCheck;
import sonia.scm.repository.RepositoryLocationResolver;
import sonia.scm.repository.RepositoryReadOnlyChecker;
import sonia.scm.security.KeyGenerator;
import sonia.scm.util.IOUtil;
@@ -47,8 +47,8 @@ public class JAXBDataStoreFactory extends FileBasedStoreFactory
private final KeyGenerator keyGenerator;
@Inject
public JAXBDataStoreFactory(SCMContextProvider contextProvider , RepositoryLocationResolver repositoryLocationResolver, KeyGenerator keyGenerator, RepositoryArchivedCheck archivedCheck) {
super(contextProvider, repositoryLocationResolver, Store.DATA, archivedCheck);
public JAXBDataStoreFactory(SCMContextProvider contextProvider , RepositoryLocationResolver repositoryLocationResolver, KeyGenerator keyGenerator, RepositoryReadOnlyChecker readOnlyChecker) {
super(contextProvider, repositoryLocationResolver, Store.DATA, readOnlyChecker);
this.keyGenerator = keyGenerator;
}

View File

@@ -36,6 +36,7 @@ import sonia.scm.io.DefaultFileSystem;
import sonia.scm.io.FileSystem;
import sonia.scm.repository.InitialRepositoryLocationResolver;
import sonia.scm.repository.Repository;
import sonia.scm.repository.RepositoryExportingCheck;
import java.nio.file.Path;
import java.util.concurrent.ExecutorService;
@@ -54,6 +55,8 @@ class XmlRepositoryDAOSynchronizationTest {
@Mock
private SCMContextProvider provider;
@Mock
private RepositoryExportingCheck repositoryExportingCheck;
private FileSystem fileSystem;
private PathBasedRepositoryLocationResolver resolver;
@@ -75,7 +78,7 @@ class XmlRepositoryDAOSynchronizationTest {
provider, new InitialRepositoryLocationResolver(), fileSystem
);
repositoryDAO = new XmlRepositoryDAO(resolver, fileSystem);
repositoryDAO = new XmlRepositoryDAO(resolver, fileSystem, repositoryExportingCheck);
}
@Test
@@ -88,7 +91,7 @@ class XmlRepositoryDAOSynchronizationTest {
}
private void assertCreated() {
XmlRepositoryDAO assertionDao = new XmlRepositoryDAO(resolver, fileSystem);
XmlRepositoryDAO assertionDao = new XmlRepositoryDAO(resolver, fileSystem, repositoryExportingCheck);
assertThat(assertionDao.getAll()).hasSize(CREATION_COUNT);
}
@@ -97,7 +100,7 @@ class XmlRepositoryDAOSynchronizationTest {
void shouldCreateALotOfRepositoriesInParallel() throws InterruptedException {
ExecutorService executors = Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors());
final XmlRepositoryDAO repositoryDAO = new XmlRepositoryDAO(resolver, fileSystem);
final XmlRepositoryDAO repositoryDAO = new XmlRepositoryDAO(resolver, fileSystem, repositoryExportingCheck);
for (int i=0; i<CREATION_COUNT; i++) {
executors.submit(create(repositoryDAO, i));
}

View File

@@ -41,6 +41,7 @@ import sonia.scm.io.DefaultFileSystem;
import sonia.scm.io.FileSystem;
import sonia.scm.repository.NamespaceAndName;
import sonia.scm.repository.Repository;
import sonia.scm.repository.RepositoryExportingCheck;
import sonia.scm.repository.RepositoryLocationResolver;
import sonia.scm.repository.RepositoryPermission;
import sonia.scm.store.StoreReadOnlyException;
@@ -72,8 +73,10 @@ class XmlRepositoryDAOTest {
@Mock
private PathBasedRepositoryLocationResolver locationResolver;
private Consumer<BiConsumer<String, Path>> triggeredOnForAllLocations = none -> {};
@Mock
private RepositoryExportingCheck repositoryExportingCheck;
private FileSystem fileSystem = new DefaultFileSystem();
private final FileSystem fileSystem = new DefaultFileSystem();
private XmlRepositoryDAO dao;
@@ -120,7 +123,7 @@ class XmlRepositoryDAOTest {
@BeforeEach
void createDAO() {
dao = new XmlRepositoryDAO(locationResolver, fileSystem);
dao = new XmlRepositoryDAO(locationResolver, fileSystem, repositoryExportingCheck);
}
@Test
@@ -245,6 +248,15 @@ class XmlRepositoryDAOTest {
assertThrows(StoreReadOnlyException.class, () -> dao.modify(heartOfGold));
}
@Test
void shouldNotModifyExportingRepository() {
when(repositoryExportingCheck.isExporting(REPOSITORY)).thenReturn(true);
dao.add(REPOSITORY);
Repository heartOfGold = createRepository("42");
assertThrows(StoreReadOnlyException.class, () -> dao.modify(heartOfGold));
}
@Test
void shouldRemoveRepository() {
dao.add(REPOSITORY);
@@ -268,6 +280,15 @@ class XmlRepositoryDAOTest {
assertThrows(StoreReadOnlyException.class, () -> dao.delete(REPOSITORY));
}
@Test
void shouldNotRemoveExportingRepository() {
when(repositoryExportingCheck.isExporting(REPOSITORY)).thenReturn(true);
dao.add(REPOSITORY);
assertThat(dao.contains("42")).isTrue();
assertThrows(StoreReadOnlyException.class, () -> dao.delete(REPOSITORY));
}
@Test
void shouldRenameTheRepository() {
dao.add(REPOSITORY);
@@ -317,8 +338,9 @@ class XmlRepositoryDAOTest {
dao.add(REPOSITORY);
String content = getXmlFileContent(REPOSITORY.getId());
assertThat(content).containsSubsequence("trillian", "<verb>read</verb>", "<verb>write</verb>");
assertThat(content).containsSubsequence("vogons", "<verb>delete</verb>");
assertThat(content)
.containsSubsequence("trillian", "<verb>read</verb>", "<verb>write</verb>")
.containsSubsequence("vogons", "<verb>delete</verb>");
}
@Test
@@ -372,7 +394,7 @@ class XmlRepositoryDAOTest {
mockExistingPath();
// when
XmlRepositoryDAO dao = new XmlRepositoryDAO(locationResolver, fileSystem);
XmlRepositoryDAO dao = new XmlRepositoryDAO(locationResolver, fileSystem, repositoryExportingCheck);
// then
assertThat(dao.contains(new NamespaceAndName("space", "existing"))).isTrue();
@@ -383,7 +405,7 @@ class XmlRepositoryDAOTest {
// given
mockExistingPath();
XmlRepositoryDAO dao = new XmlRepositoryDAO(locationResolver, fileSystem);
XmlRepositoryDAO dao = new XmlRepositoryDAO(locationResolver, fileSystem, repositoryExportingCheck);
// when
dao.refresh();

View File

@@ -30,7 +30,7 @@ import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import sonia.scm.AbstractTestBase;
import sonia.scm.repository.Repository;
import sonia.scm.repository.RepositoryArchivedCheck;
import sonia.scm.repository.RepositoryReadOnlyChecker;
import sonia.scm.repository.RepositoryTestData;
import sonia.scm.security.UUIDKeyGenerator;
@@ -51,8 +51,8 @@ import static org.mockito.Mockito.when;
class FileBlobStoreTest extends AbstractTestBase
{
private Repository repository = RepositoryTestData.createHeartOfGold();
private RepositoryArchivedCheck archivedCheck = mock(RepositoryArchivedCheck.class);
private final Repository repository = RepositoryTestData.createHeartOfGold();
private final RepositoryReadOnlyChecker readOnlyChecker = mock(RepositoryReadOnlyChecker.class);
private BlobStore store;
@BeforeEach
@@ -191,7 +191,7 @@ class FileBlobStoreTest extends AbstractTestBase
@BeforeEach
void setRepositoryArchived() {
store.create("1"); // store for test must not be empty
when(archivedCheck.isArchived(repository.getId())).thenReturn(true);
when(readOnlyChecker.isReadOnly(repository.getId())).thenReturn(true);
createBlobStore();
}
@@ -227,6 +227,6 @@ class FileBlobStoreTest extends AbstractTestBase
protected BlobStoreFactory createBlobStoreFactory()
{
return new FileBlobStoreFactory(contextProvider, repositoryLocationResolver, new UUIDKeyGenerator(), archivedCheck);
return new FileBlobStoreFactory(contextProvider, repositoryLocationResolver, new UUIDKeyGenerator(), readOnlyChecker);
}
}

View File

@@ -26,7 +26,7 @@ package sonia.scm.store;
import org.junit.Test;
import sonia.scm.repository.Repository;
import sonia.scm.repository.RepositoryArchivedCheck;
import sonia.scm.repository.RepositoryReadOnlyChecker;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
@@ -41,17 +41,16 @@ import static org.mockito.Mockito.when;
*/
public class JAXBConfigurationStoreTest extends StoreTestBase {
private final RepositoryArchivedCheck archivedCheck = mock(RepositoryArchivedCheck.class);
private final RepositoryReadOnlyChecker readOnlyChecker = mock(RepositoryReadOnlyChecker.class);
@Override
protected ConfigurationStoreFactory createStoreFactory()
{
return new JAXBConfigurationStoreFactory(contextProvider, repositoryLocationResolver, archivedCheck);
return new JAXBConfigurationStoreFactory(contextProvider, repositoryLocationResolver, readOnlyChecker);
}
@Test
@SuppressWarnings("unchecked")
public void shouldStoreAndLoadInRepository()
{
Repository repository = new Repository("id", "git", "ns", "n");
@@ -70,17 +69,17 @@ public class JAXBConfigurationStoreTest extends StoreTestBase {
@Test
@SuppressWarnings("unchecked")
public void shouldNotWriteArchivedRepository()
{
Repository repository = new Repository("id", "git", "ns", "n");
when(archivedCheck.isArchived("id")).thenReturn(true);
when(readOnlyChecker.isReadOnly("id")).thenReturn(true);
ConfigurationStore<StoreObject> store = createStoreFactory()
.withType(StoreObject.class)
.withName("test")
.forRepository(repository)
.build();
assertThrows(RuntimeException.class, () -> store.set(new StoreObject("value")));
StoreObject storeObject = new StoreObject("value");
assertThrows(RuntimeException.class, () -> store.set(storeObject));
}
}

View File

@@ -28,7 +28,7 @@ package sonia.scm.store;
import org.junit.Test;
import sonia.scm.repository.Repository;
import sonia.scm.repository.RepositoryArchivedCheck;
import sonia.scm.repository.RepositoryReadOnlyChecker;
import sonia.scm.security.UUIDKeyGenerator;
import static org.junit.Assert.assertEquals;
@@ -42,12 +42,12 @@ import static org.mockito.Mockito.when;
*/
public class JAXBDataStoreTest extends DataStoreTestBase {
private final RepositoryArchivedCheck archivedCheck = mock(RepositoryArchivedCheck.class);
private final RepositoryReadOnlyChecker readOnlyChecker = mock(RepositoryReadOnlyChecker.class);
@Override
protected DataStoreFactory createDataStoreFactory()
{
return new JAXBDataStoreFactory(contextProvider, repositoryLocationResolver, new UUIDKeyGenerator(), archivedCheck);
return new JAXBDataStoreFactory(contextProvider, repositoryLocationResolver, new UUIDKeyGenerator(), readOnlyChecker);
}
@Override
@@ -80,7 +80,7 @@ public class JAXBDataStoreTest extends DataStoreTestBase {
@Test(expected = StoreReadOnlyException.class)
public void shouldNotStoreForReadOnlyRepository()
{
when(archivedCheck.isArchived(repository.getId())).thenReturn(true);
when(readOnlyChecker.isReadOnly(repository.getId())).thenReturn(true);
getDataStore(StoreObject.class, repository).put("abc", new StoreObject("abc_value"));
}
}

View File

@@ -29,7 +29,7 @@ import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import sonia.scm.SCMContextProvider;
import sonia.scm.Stage;
import sonia.scm.repository.RepositoryArchivedCheck;
import sonia.scm.repository.RepositoryReadOnlyChecker;
import sonia.scm.security.KeyGenerator;
import sonia.scm.store.JAXBConfigurationEntryStoreFactory;
import sonia.scm.update.RepositoryV1PropertyReader;
@@ -111,8 +111,8 @@ class XmlV1PropertyDAOTest {
Files.createDirectories(configPath);
Path propFile = configPath.resolve("repository-properties-v1.xml");
Files.write(propFile, PROPERTIES.getBytes());
RepositoryArchivedCheck archivedCheck = mock(RepositoryArchivedCheck.class);
XmlV1PropertyDAO dao = new XmlV1PropertyDAO(new JAXBConfigurationEntryStoreFactory(new SimpleContextProvider(temp), null, new SimpleKeyGenerator(), archivedCheck));
RepositoryReadOnlyChecker readOnlyChecker = mock(RepositoryReadOnlyChecker.class);
XmlV1PropertyDAO dao = new XmlV1PropertyDAO(new JAXBConfigurationEntryStoreFactory(new SimpleContextProvider(temp), null, new SimpleKeyGenerator(), readOnlyChecker));
dao.getProperties(new RepositoryV1PropertyReader())
.forEachEntry((key, prop) -> {

View File

@@ -48475,7 +48475,7 @@ exports[`Storyshots RepositoryEntry Archived 1`] = `
</strong>
<span
className="RepositoryEntry__ArchiveTag-sc-6jys82-0 lgBbpU"
className="RepositoryEntry__RepositoryTag-sc-6jys82-0 cFBCjw"
title="archive.tooltip"
>
repository.archived
@@ -49023,6 +49023,314 @@ exports[`Storyshots RepositoryEntry Default 1`] = `
</div>
`;
exports[`Storyshots RepositoryEntry Exporting 1`] = `
<div
className="RepositoryEntrystories__Spacing-toppdg-0 iIzVNZ box box-link-shadow"
>
<a
className="overlay-column"
href="/repo/hitchhiker/heartOfGold"
onClick={[Function]}
/>
<article
className="CardColumn__NoEventWrapper-sc-1w6lsih-0 eUWboI media"
>
<figure
className="CardColumn__AvatarWrapper-sc-1w6lsih-1 lhzEPm media-left"
>
<p
className="image is-64x64"
>
<img
alt="Logo"
src="test-file-stub"
/>
</p>
</figure>
<div
className="CardColumn__FlexFullHeight-sc-1w6lsih-2 hWRPir media-content text-box is-flex"
>
<div
className="is-flex"
>
<div
className="CardColumn__ContentLeft-sc-1w6lsih-4 iRVRBC content"
>
<p
className="shorten-text is-marginless"
>
<strong>
heartOfGold
</strong>
<span
className="RepositoryEntry__RepositoryTag-sc-6jys82-0 cFBCjw"
title="exporting.tooltip"
>
repository.exporting
</span>
</p>
<p
className="shorten-text"
>
The starship Heart of Gold was the first spacecraft to make use of the Infinite Improbability Drive
</p>
</div>
</div>
<div
className="CardColumn__FooterWrapper-sc-1w6lsih-3 hzknmV level is-flex"
>
<div
className="CardColumn__RightMarginDiv-sc-1w6lsih-6 bnJfDV level-left is-hidden-mobile"
>
<a
className="RepositoryEntryLink__PointerEventsLink-sc-1hpqj0w-0 iZuqoP level-item"
href="/repo/hitchhiker/heartOfGold/branches/"
onClick={[Function]}
>
<span
className="tooltip has-tooltip-top"
data-tooltip="repositoryRoot.tooltip.branches"
>
<i
className="fas fa-code-branch has-text-inherit fa-lg"
/>
</span>
</a>
<a
className="RepositoryEntryLink__PointerEventsLink-sc-1hpqj0w-0 iZuqoP level-item"
href="/repo/hitchhiker/heartOfGold/tags/"
onClick={[Function]}
>
<span
className="tooltip has-tooltip-top"
data-tooltip="repositoryRoot.tooltip.tags"
>
<i
className="fas fa-tags has-text-inherit fa-lg"
/>
</span>
</a>
<a
className="RepositoryEntryLink__PointerEventsLink-sc-1hpqj0w-0 iZuqoP level-item"
href="/repo/hitchhiker/heartOfGold/code/changesets/"
onClick={[Function]}
>
<span
className="tooltip has-tooltip-top"
data-tooltip="repositoryRoot.tooltip.commits"
>
<i
className="fas fa-exchange-alt has-text-inherit fa-lg"
/>
</span>
</a>
<a
className="RepositoryEntryLink__PointerEventsLink-sc-1hpqj0w-0 iZuqoP level-item"
href="/repo/hitchhiker/heartOfGold/code/sources/"
onClick={[Function]}
>
<span
className="tooltip has-tooltip-top"
data-tooltip="repositoryRoot.tooltip.sources"
>
<i
className="fas fa-code has-text-inherit fa-lg"
/>
</span>
</a>
<a
className="RepositoryEntryLink__PointerEventsLink-sc-1hpqj0w-0 iZuqoP level-item"
href="/repo/hitchhiker/heartOfGold/settings/general"
onClick={[Function]}
>
<span
className="tooltip has-tooltip-top"
data-tooltip="repositoryRoot.tooltip.settings"
>
<i
className="fas fa-cog has-text-inherit fa-lg"
/>
</span>
</a>
</div>
<div
className="CardColumn__InheritFlexShrinkDiv-sc-1w6lsih-7 kdhCxo level-right is-block is-mobile is-marginless shorten-text"
>
<small
className="level-item"
>
<time
className="DateElement-sc-1schp8c-0 gkptML"
title="2020-03-23 09:26:01"
>
3 days ago
</time>
</small>
</div>
</div>
</div>
</article>
</div>
`;
exports[`Storyshots RepositoryEntry MultiRepositoryTags 1`] = `
<div
className="RepositoryEntrystories__Spacing-toppdg-0 iIzVNZ box box-link-shadow"
>
<a
className="overlay-column"
href="/repo/hitchhiker/heartOfGold"
onClick={[Function]}
/>
<article
className="CardColumn__NoEventWrapper-sc-1w6lsih-0 eUWboI media"
>
<figure
className="CardColumn__AvatarWrapper-sc-1w6lsih-1 lhzEPm media-left"
>
<p
className="image is-64x64"
>
<img
alt="Logo"
src="test-file-stub"
/>
</p>
</figure>
<div
className="CardColumn__FlexFullHeight-sc-1w6lsih-2 hWRPir media-content text-box is-flex"
>
<div
className="is-flex"
>
<div
className="CardColumn__ContentLeft-sc-1w6lsih-4 iRVRBC content"
>
<p
className="shorten-text is-marginless"
>
<strong>
heartOfGold
</strong>
<span
className="RepositoryEntry__RepositoryTag-sc-6jys82-0 cFBCjw"
title="archive.tooltip"
>
repository.archived
</span>
<span
className="RepositoryEntry__RepositoryTag-sc-6jys82-0 cFBCjw"
title="exporting.tooltip"
>
repository.exporting
</span>
</p>
<p
className="shorten-text"
>
The starship Heart of Gold was the first spacecraft to make use of the Infinite Improbability Drive
</p>
</div>
</div>
<div
className="CardColumn__FooterWrapper-sc-1w6lsih-3 hzknmV level is-flex"
>
<div
className="CardColumn__RightMarginDiv-sc-1w6lsih-6 bnJfDV level-left is-hidden-mobile"
>
<a
className="RepositoryEntryLink__PointerEventsLink-sc-1hpqj0w-0 iZuqoP level-item"
href="/repo/hitchhiker/heartOfGold/branches/"
onClick={[Function]}
>
<span
className="tooltip has-tooltip-top"
data-tooltip="repositoryRoot.tooltip.branches"
>
<i
className="fas fa-code-branch has-text-inherit fa-lg"
/>
</span>
</a>
<a
className="RepositoryEntryLink__PointerEventsLink-sc-1hpqj0w-0 iZuqoP level-item"
href="/repo/hitchhiker/heartOfGold/tags/"
onClick={[Function]}
>
<span
className="tooltip has-tooltip-top"
data-tooltip="repositoryRoot.tooltip.tags"
>
<i
className="fas fa-tags has-text-inherit fa-lg"
/>
</span>
</a>
<a
className="RepositoryEntryLink__PointerEventsLink-sc-1hpqj0w-0 iZuqoP level-item"
href="/repo/hitchhiker/heartOfGold/code/changesets/"
onClick={[Function]}
>
<span
className="tooltip has-tooltip-top"
data-tooltip="repositoryRoot.tooltip.commits"
>
<i
className="fas fa-exchange-alt has-text-inherit fa-lg"
/>
</span>
</a>
<a
className="RepositoryEntryLink__PointerEventsLink-sc-1hpqj0w-0 iZuqoP level-item"
href="/repo/hitchhiker/heartOfGold/code/sources/"
onClick={[Function]}
>
<span
className="tooltip has-tooltip-top"
data-tooltip="repositoryRoot.tooltip.sources"
>
<i
className="fas fa-code has-text-inherit fa-lg"
/>
</span>
</a>
<a
className="RepositoryEntryLink__PointerEventsLink-sc-1hpqj0w-0 iZuqoP level-item"
href="/repo/hitchhiker/heartOfGold/settings/general"
onClick={[Function]}
>
<span
className="tooltip has-tooltip-top"
data-tooltip="repositoryRoot.tooltip.settings"
>
<i
className="fas fa-cog has-text-inherit fa-lg"
/>
</span>
</a>
</div>
<div
className="CardColumn__InheritFlexShrinkDiv-sc-1w6lsih-7 kdhCxo level-right is-block is-mobile is-marginless shorten-text"
>
<small
className="level-item"
>
<time
className="DateElement-sc-1schp8c-0 gkptML"
title="2020-03-23 09:26:01"
>
3 days ago
</time>
</small>
</div>
</div>
</div>
</article>
</div>
`;
exports[`Storyshots RepositoryEntry Quick Link EP 1`] = `
<div
className="RepositoryEntrystories__Spacing-toppdg-0 iIzVNZ box box-link-shadow"

View File

@@ -75,6 +75,8 @@ const QuickLink = (
);
const archivedRepository = { ...repository, archived: true };
const exportingRepository = { ...repository, exporting: true };
const archivedExportingRepository = { ...repository, archived: true, exporting: true };
storiesOf("RepositoryEntry", module)
.addDecorator((story) => <MemoryRouter initialEntries={["/"]}>{story()}</MemoryRouter>)
@@ -101,4 +103,14 @@ storiesOf("RepositoryEntry", module)
const binder = new Binder("title");
bindAvatar(binder, Git);
return withBinder(binder, archivedRepository);
})
.add("Exporting", () => {
const binder = new Binder("title");
bindAvatar(binder, Git);
return withBinder(binder, exportingRepository);
})
.add("MultiRepositoryTags", () => {
const binder = new Binder("title");
bindAvatar(binder, Git);
return withBinder(binder, archivedExportingRepository);
});

View File

@@ -39,7 +39,7 @@ type Props = WithTranslation & {
baseDate?: DateProp;
};
const ArchiveTag = styled.span`
const RepositoryTag = styled.span`
margin-left: 0.2rem;
background-color: #9a9a9a;
padding: 0.25rem;
@@ -145,13 +145,19 @@ class RepositoryEntry extends React.Component<Props> {
createTitle = () => {
const { repository, t } = this.props;
const archivedFlag = repository.archived && (
<ArchiveTag title={t("archive.tooltip")}>{t("repository.archived")}</ArchiveTag>
);
const repositoryFlags = [];
if (repository.archived) {
repositoryFlags.push(<RepositoryTag title={t("archive.tooltip")}>{t("repository.archived")}</RepositoryTag>);
}
if (repository.exporting) {
repositoryFlags.push(<RepositoryTag title={t("exporting.tooltip")}>{t("repository.exporting")}</RepositoryTag>);
}
return (
<>
<ExtensionPoint name="repository.card.beforeTitle" props={{ repository }} />
<strong>{repository.name}</strong> {archivedFlag}
<strong>{repository.name}</strong> {repositoryFlags.map(flag => flag)}
</>
);
};

View File

@@ -33,6 +33,7 @@ export type Repository = {
creationDate?: string;
lastModified?: string;
archived?: boolean;
exporting?: boolean;
_links: Links;
};

View File

@@ -7,7 +7,8 @@
"description": "Beschreibung",
"creationDate": "Erstellt",
"lastModified": "Zuletzt bearbeitet",
"archived": "archiviert"
"archived": "archiviert",
"exporting": "Wird exportiert"
},
"validation": {
"namespace-invalid": "Der Namespace des Repository ist ungültig",
@@ -252,6 +253,7 @@
},
"export": {
"subtitle": "Repository exportieren",
"notification": "Achtung: Während eines laufenden Exports kann auf das Repository nur lesend zugegriffen werden.",
"compressed": {
"label": "Komprimieren",
"helpText": "Export Datei vor dem Download komprimieren. Reduziert die Downloadgröße."
@@ -399,6 +401,9 @@
"archive": {
"tooltip": "Nur lesender Zugriff möglich. Das Archiv kann nicht verändert werden."
},
"exporting": {
"tooltip": "Nur lesender Zugriff möglich. Das Repository wird derzeit exportiert."
},
"diff": {
"jumpToSource": "Zur Quelldatei springen",
"jumpToTarget": "Zur vorherigen Version der Datei springen",

View File

@@ -7,7 +7,8 @@
"description": "Description",
"creationDate": "Creation Date",
"lastModified": "Last Modified",
"archived": "archived"
"archived": "archived",
"exporting": "exporting"
},
"validation": {
"namespace-invalid": "The repository namespace is invalid",
@@ -252,6 +253,7 @@
},
"export": {
"subtitle": "Repository Export",
"notification": "Attention: During the export the repository cannot be modified.",
"compressed": {
"label": "Compress",
"helpText": "Compress the export dump size to reduce the download size."
@@ -399,6 +401,9 @@
"archive": {
"tooltip": "Read only. The archive cannot be changed."
},
"exporting": {
"tooltip": "Read only. The repository is currently being exported."
},
"diff": {
"changes": {
"add": "added",

View File

@@ -56,6 +56,9 @@ const ExportRepository: FC<Props> = ({ repository }) => {
<>
<hr />
<Subtitle subtitle={t("export.subtitle")} />
<Notification type="inherit">
{t("export.notification")}
</Notification>
<>
<Checkbox
checked={fullExport || compressed}

View File

@@ -41,7 +41,7 @@ import {
StateMenuContextProvider,
SubNavigation,
Tooltip,
urls,
urls
} from "@scm-manager/ui-components";
import { fetchRepoByName, getFetchRepoFailure, getRepository, isFetchRepoPending } from "../modules/repos";
import RepositoryDetails from "../components/RepositoryDetails";
@@ -75,7 +75,7 @@ type Props = RouteComponentProps &
fetchRepoByName: (link: string, namespace: string, name: string) => void;
};
const ArchiveTag = styled.span`
const RepositoryTag = styled.span`
margin-left: 0.2rem;
background-color: #9a9a9a;
padding: 0.4rem;
@@ -153,7 +153,7 @@ class RepositoryRoot extends React.Component<Props> {
const extensionProps = {
repository,
url,
indexLinks,
indexLinks
};
const redirectUrlFactory = binder.getExtension("repository.redirect", this.props);
@@ -164,16 +164,16 @@ class RepositoryRoot extends React.Component<Props> {
redirectedUrl = url + "/info";
}
const fileControlFactoryFactory: (changeset: Changeset) => FileControlFactory = (changeset) => (file) => {
const fileControlFactoryFactory: (changeset: Changeset) => FileControlFactory = changeset => file => {
const baseUrl = `${url}/code/sources`;
const sourceLink = file.newPath && {
url: `${baseUrl}/${changeset.id}/${file.newPath}/`,
label: t("diff.jumpToSource"),
label: t("diff.jumpToSource")
};
const targetLink = file.oldPath &&
changeset._embedded?.parents?.length === 1 && {
url: `${baseUrl}/${changeset._embedded.parents[0].id}/${file.oldPath}`,
label: t("diff.jumpToTarget"),
label: t("diff.jumpToTarget")
};
const links = [];
@@ -199,11 +199,22 @@ class RepositoryRoot extends React.Component<Props> {
return links ? links.map(({ url, label }) => <JumpToFileButton tooltip={label} link={url} />) : null;
};
const archivedFlag = repository.archived && (
const repositoryFlags = [];
if (repository.archived) {
repositoryFlags.push(
<Tooltip message={t("archive.tooltip")}>
<ArchiveTag className="is-size-6">{t("repository.archived")}</ArchiveTag>
<RepositoryTag className="is-size-6">{t("repository.archived")}</RepositoryTag>
</Tooltip>
);
}
if (repository.exporting) {
repositoryFlags.push(
<Tooltip message={t("exporting.tooltip")}>
<RepositoryTag className="is-size-6">{t("repository.exporting")}</RepositoryTag>
</Tooltip>
);
}
const titleComponent = (
<>
@@ -222,7 +233,7 @@ class RepositoryRoot extends React.Component<Props> {
afterTitle={
<>
<ExtensionPoint name={"repository.afterTitle"} props={{ repository }} />
{archivedFlag}
{repositoryFlags.map(flag => flag)}
</>
}
>
@@ -360,7 +371,7 @@ const mapStateToProps = (state: any, ownProps: Props) => {
loading,
error,
repoLink,
indexLinks,
indexLinks
};
};
@@ -368,7 +379,7 @@ const mapDispatchToProps = (dispatch: any) => {
return {
fetchRepoByName: (link: string, namespace: string, name: string) => {
dispatch(fetchRepoByName(link, namespace, name));
},
}
};
};

View File

@@ -58,6 +58,7 @@ public class RepositoryDto extends HalRepresentation implements CreateRepository
@NotEmpty
private String type;
private boolean archived;
private boolean exporting;
RepositoryDto(Links links, Embedded embedded) {
super(links, embedded);

View File

@@ -161,12 +161,7 @@ public class RepositoryExportResource {
@PathParam("name") String name
) {
Repository repository = getVerifiedRepository(namespace, name);
StreamingOutput output = os -> fullScmRepositoryExporter.export(repository, os);
return Response
.ok(output, "application/x-gzip")
.header("content-disposition", createContentDispositionHeaderValue(repository, "tar.gz"))
.build();
return exportFullRepository(repository);
}
private Repository getVerifiedRepository(String namespace, String name) {
@@ -186,6 +181,15 @@ public class RepositoryExportResource {
return repository;
}
private Response exportFullRepository(Repository repository) {
StreamingOutput output = os -> fullScmRepositoryExporter.export(repository, os);
return Response
.ok(output, "application/x-gzip")
.header("content-disposition", createContentDispositionHeaderValue(repository, "tar.gz"))
.build();
}
private Response exportRepository(Repository repository, boolean compressed) {
StreamingOutput output;
String fileExtension;

View File

@@ -27,9 +27,12 @@ package sonia.scm.api.v2.resources;
import de.otto.edison.hal.Embedded;
import de.otto.edison.hal.Link;
import de.otto.edison.hal.Links;
import org.mapstruct.AfterMapping;
import org.mapstruct.Mapper;
import org.mapstruct.MappingTarget;
import org.mapstruct.ObjectFactory;
import sonia.scm.config.ScmConfiguration;
import sonia.scm.repository.DefaultRepositoryExportingCheck;
import sonia.scm.repository.Feature;
import sonia.scm.repository.HealthCheckFailure;
import sonia.scm.repository.NamespaceStrategy;
@@ -70,6 +73,11 @@ public abstract class RepositoryToRepositoryDtoMapper extends BaseMapper<Reposit
@Override
public abstract RepositoryDto map(Repository modelObject);
@AfterMapping
void setExporting(Repository repository, @MappingTarget RepositoryDto repositoryDto) {
repositoryDto.setExporting(DefaultRepositoryExportingCheck.isRepositoryExporting(repository.getId()));
}
@ObjectFactory
RepositoryDto createDto(Repository repository) {
Links.Builder linksBuilder = linkingTo().self(resourceLinks.repository().self(repository.getNamespace(), repository.getName()));

View File

@@ -29,6 +29,7 @@ import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream;
import org.apache.commons.compress.compressors.gzip.GzipCompressorOutputStream;
import sonia.scm.ContextEntry;
import sonia.scm.repository.Repository;
import sonia.scm.repository.RepositoryExportingCheck;
import sonia.scm.repository.api.ExportFailedException;
import sonia.scm.repository.api.RepositoryService;
import sonia.scm.repository.api.RepositoryServiceFactory;
@@ -54,25 +55,36 @@ public class FullScmRepositoryExporter {
private final RepositoryServiceFactory serviceFactory;
private final TarArchiveRepositoryStoreExporter storeExporter;
private final WorkdirProvider workdirProvider;
private final RepositoryExportingCheck repositoryExportingCheck;
@Inject
public FullScmRepositoryExporter(EnvironmentInformationXmlGenerator environmentGenerator,
RepositoryMetadataXmlGenerator metadataGenerator,
RepositoryServiceFactory serviceFactory,
TarArchiveRepositoryStoreExporter storeExporter, WorkdirProvider workdirProvider) {
TarArchiveRepositoryStoreExporter storeExporter,
WorkdirProvider workdirProvider,
RepositoryExportingCheck repositoryExportingCheck) {
this.environmentGenerator = environmentGenerator;
this.metadataGenerator = metadataGenerator;
this.serviceFactory = serviceFactory;
this.storeExporter = storeExporter;
this.workdirProvider = workdirProvider;
this.repositoryExportingCheck = repositoryExportingCheck;
}
public void export(Repository repository, OutputStream outputStream) {
repositoryExportingCheck.withExportingLock(repository, () -> {
exportInLock(repository, outputStream);
return null;
});
}
private void exportInLock(Repository repository, OutputStream outputStream) {
try (
RepositoryService service = serviceFactory.create(repository);
BufferedOutputStream bos = new BufferedOutputStream(outputStream);
GzipCompressorOutputStream gzos = new GzipCompressorOutputStream(bos);
TarArchiveOutputStream taos = new TarArchiveOutputStream(gzos);
TarArchiveOutputStream taos = new TarArchiveOutputStream(gzos)
) {
writeEnvironmentData(taos);
writeMetadata(repository, taos);

View File

@@ -36,8 +36,10 @@ import sonia.scm.io.FileSystem;
import sonia.scm.lifecycle.DefaultRestarter;
import sonia.scm.lifecycle.Restarter;
import sonia.scm.plugin.PluginLoader;
import sonia.scm.repository.DefaultRepositoryExportingCheck;
import sonia.scm.repository.EventDrivenRepositoryArchiveCheck;
import sonia.scm.repository.RepositoryArchivedCheck;
import sonia.scm.repository.RepositoryExportingCheck;
import sonia.scm.repository.RepositoryLocationResolver;
import sonia.scm.repository.xml.MetadataStore;
import sonia.scm.repository.xml.PathBasedRepositoryLocationResolver;
@@ -100,6 +102,7 @@ public class BootstrapModule extends AbstractModule {
// bind core
bind(RepositoryArchivedCheck.class, EventDrivenRepositoryArchiveCheck.class);
bind(RepositoryExportingCheck.class, DefaultRepositoryExportingCheck.class);
bind(ConfigurationStoreFactory.class, JAXBConfigurationStoreFactory.class);
bind(ConfigurationEntryStoreFactory.class, JAXBConfigurationEntryStoreFactory.class);
bind(DataStoreFactory.class, JAXBDataStoreFactory.class);

View File

@@ -39,7 +39,6 @@ import sonia.scm.NoChangesMadeException;
import sonia.scm.NotFoundException;
import sonia.scm.SCMContextProvider;
import sonia.scm.Type;
import sonia.scm.config.ScmConfiguration;
import sonia.scm.event.ScmEventBus;
import sonia.scm.security.AuthorizationChangedEvent;
import sonia.scm.security.KeyGenerator;
@@ -79,7 +78,6 @@ public class DefaultRepositoryManager extends AbstractRepositoryManager {
private static final String THREAD_NAME = "Hook-%s";
private static final Logger logger =
LoggerFactory.getLogger(DefaultRepositoryManager.class);
private final ScmConfiguration configuration;
private final ExecutorService executorService;
private final Map<String, RepositoryHandler> handlerMap;
private final KeyGenerator keyGenerator;
@@ -89,11 +87,9 @@ public class DefaultRepositoryManager extends AbstractRepositoryManager {
private final ManagerDaoAdapter<Repository> managerDaoAdapter;
@Inject
public DefaultRepositoryManager(ScmConfiguration configuration,
SCMContextProvider contextProvider, KeyGenerator keyGenerator,
public DefaultRepositoryManager(SCMContextProvider contextProvider, KeyGenerator keyGenerator,
RepositoryDAO repositoryDAO, Set<RepositoryHandler> handlerSet,
Provider<NamespaceStrategy> namespaceStrategyProvider) {
this.configuration = configuration;
this.keyGenerator = keyGenerator;
this.repositoryDAO = repositoryDAO;
this.namespaceStrategyProvider = namespaceStrategyProvider;

View File

@@ -326,6 +326,10 @@
"4hSNNTBiu1": {
"displayName": "Falscher Repository Typ",
"description": "Der gegebene Typ entspricht nicht dem Typen des Repositories."
},
"1mSNlpe1V1": {
"displayName": "Repository wird exportiert",
"description": "Das Repository wird momentan exportiert und darf nicht modifiziert werden."
}
},
"namespaceStrategies": {

View File

@@ -326,6 +326,10 @@
"4hSNNTBiu1": {
"displayName": "Wrong repository type",
"description": "The given type does not match the type of the repository."
},
"1mSNlpe1V1": {
"displayName": "Repository is being exported",
"description": "The repository is being exported and therefore must not be modified."
}
},
"namespaceStrategies": {

View File

@@ -88,6 +88,7 @@ import java.util.Set;
import java.util.UUID;
import java.util.function.Consumer;
import java.util.function.Predicate;
import java.util.function.Supplier;
import static java.util.Collections.singletonList;
import static java.util.stream.Stream.of;
@@ -104,23 +105,24 @@ import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyMap;
import static org.mockito.ArgumentMatchers.anyObject;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.RETURNS_SELF;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.mockito.MockitoAnnotations.initMocks;
import static org.mockito.MockitoAnnotations.openMocks;
@SubjectAware(
username = "trillian",
password = "secret",
configuration = "classpath:sonia/scm/repository/shiro.ini"
)
@SuppressWarnings("UnstableApiUsage")
public class RepositoryRootResourceTest extends RepositoryTestBase {
private static final String REALM = "AdminRealm";
@@ -160,6 +162,7 @@ public class RepositoryRootResourceTest extends RepositoryTestBase {
private final URI baseUri = URI.create("/");
private final ResourceLinks resourceLinks = ResourceLinksMock.createMock(baseUri);
private Repository repositoryMarkedAsExported;
@InjectMocks
private RepositoryToRepositoryDtoMapperImpl repositoryToDtoMapper;
@@ -168,7 +171,7 @@ public class RepositoryRootResourceTest extends RepositoryTestBase {
@Before
public void prepareEnvironment() {
initMocks(this);
openMocks(this);
super.repositoryToDtoMapper = repositoryToDtoMapper;
super.dtoToRepositoryMapper = dtoToRepositoryMapper;
super.manager = repositoryManager;
@@ -316,7 +319,7 @@ public class RepositoryRootResourceTest extends RepositoryTestBase {
dispatcher.invoke(request, response);
assertEquals(SC_NO_CONTENT, response.getStatus());
verify(repositoryManager).modify(anyObject());
verify(repositoryManager).modify(any());
}
@Test
@@ -336,7 +339,7 @@ public class RepositoryRootResourceTest extends RepositoryTestBase {
assertEquals(SC_CONFLICT, response.getStatus());
assertThat(response.getContentAsString()).contains("space/repo");
verify(repositoryManager, never()).modify(anyObject());
verify(repositoryManager, never()).modify(any());
}
@Test
@@ -355,7 +358,7 @@ public class RepositoryRootResourceTest extends RepositoryTestBase {
dispatcher.invoke(request, response);
assertEquals(SC_BAD_REQUEST, response.getStatus());
verify(repositoryManager, never()).modify(anyObject());
verify(repositoryManager, never()).modify(any());
}
@Test
@@ -368,7 +371,7 @@ public class RepositoryRootResourceTest extends RepositoryTestBase {
dispatcher.invoke(request, response);
assertEquals(SC_NO_CONTENT, response.getStatus());
verify(repositoryManager).delete(anyObject());
verify(repositoryManager).delete(any());
}
@Test
@@ -826,7 +829,7 @@ public class RepositoryRootResourceTest extends RepositoryTestBase {
/**
* This method is a slightly adapted copy of Lin Zaho's gist at https://gist.github.com/lin-zhao/9985191
*/
private MockHttpRequest multipartRequest(MockHttpRequest request, Map<String, InputStream> files, RepositoryDto repository) throws IOException {
private void multipartRequest(MockHttpRequest request, Map<String, InputStream> files, RepositoryDto repository) throws IOException {
String boundary = UUID.randomUUID().toString();
request.contentType("multipart/form-data; boundary=" + boundary);
@@ -864,6 +867,5 @@ public class RepositoryRootResourceTest extends RepositoryTestBase {
formWriter.flush();
}
request.setInputStream(new ByteArrayInputStream(buffer.toByteArray()));
return request;
}
}

View File

@@ -33,6 +33,7 @@ import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import sonia.scm.repository.Repository;
import sonia.scm.repository.RepositoryExportingCheck;
import sonia.scm.repository.RepositoryTestData;
import sonia.scm.repository.api.BundleCommandBuilder;
import sonia.scm.repository.api.RepositoryService;
@@ -47,6 +48,7 @@ import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collection;
import java.util.function.Supplier;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
@@ -70,6 +72,8 @@ class FullScmRepositoryExporterTest {
private TarArchiveRepositoryStoreExporter storeExporter;
@Mock
private WorkdirProvider workdirProvider;
@Mock
private RepositoryExportingCheck repositoryExportingCheck;
@InjectMocks
private FullScmRepositoryExporter exporter;
@@ -81,6 +85,7 @@ class FullScmRepositoryExporterTest {
when(serviceFactory.create(REPOSITORY)).thenReturn(repositoryService);
when(environmentGenerator.generate()).thenReturn(new byte[0]);
when(metadataGenerator.generate(REPOSITORY)).thenReturn(new byte[0]);
when(repositoryExportingCheck.withExportingLock(any(), any())).thenAnswer(invocation -> invocation.getArgument(1, Supplier.class).get());
}
@Test
@@ -96,6 +101,7 @@ class FullScmRepositoryExporterTest {
verify(environmentGenerator, times(1)).generate();
verify(metadataGenerator, times(1)).generate(REPOSITORY);
verify(bundleCommandBuilder, times(1)).bundle(any(OutputStream.class));
verify(repositoryExportingCheck).withExportingLock(eq(REPOSITORY), any());
workDirsCreated.forEach(wd -> assertThat(wd).doesNotExist());
}

View File

@@ -108,7 +108,6 @@ public class DefaultRepositoryManagerPerfTest {
Set<RepositoryHandler> handlerSet = ImmutableSet.of(repositoryHandler);
NamespaceStrategy namespaceStrategy = mock(NamespaceStrategy.class);
repositoryManager = new DefaultRepositoryManager(
configuration,
contextProvider,
keyGenerator,
repositoryDAO,

View File

@@ -65,7 +65,6 @@ import java.util.Map;
import java.util.Set;
import java.util.Stack;
import static java.util.Collections.emptyList;
import static java.util.Collections.emptySet;
import static org.hamcrest.Matchers.containsInAnyOrder;
import static org.hamcrest.Matchers.hasProperty;
@@ -109,7 +108,7 @@ public class DefaultRepositoryManagerTest extends ManagerTestBase<Repository> {
private RepositoryDAO repositoryDAO;
{
static {
ThreadContext.unbindSubject();
}
@@ -121,8 +120,6 @@ public class DefaultRepositoryManagerTest extends ManagerTestBase<Repository> {
private NamespaceStrategy namespaceStrategy = mock(NamespaceStrategy.class);
private ScmConfiguration configuration;
private String mockedNamespace = "default_namespace";
@Before
@@ -552,11 +549,9 @@ public class DefaultRepositoryManagerTest extends ManagerTestBase<Repository> {
handlerSet.add(createRepositoryHandler("hg", "Mercurial"));
handlerSet.add(createRepositoryHandler("svn", "SVN"));
this.configuration = new ScmConfiguration();
when(namespaceStrategy.createNamespace(Mockito.any(Repository.class))).thenAnswer(invocation -> mockedNamespace);
return new DefaultRepositoryManager(configuration, contextProvider,
return new DefaultRepositoryManager(contextProvider,
keyGenerator, repositoryDAO, handlerSet, Providers.of(namespaceStrategy));
}

View File

@@ -27,7 +27,6 @@ package sonia.scm.update;
import org.junit.jupiter.api.Test;
import sonia.scm.migration.UpdateStep;
import sonia.scm.store.ConfigurationEntryStoreFactory;
import sonia.scm.store.InMemoryConfigurationEntryStore;
import sonia.scm.store.InMemoryConfigurationEntryStoreFactory;
import sonia.scm.version.Version;