Merge branch 'stable-3.3' into stable-3.2 * stable-3.3: Fix ApplyObjectIT.shouldApplyRefMetaObject test for apply object Introduce E2E apply object REST-API metrics Add missing @Override to parseRemotes Add more logging for the apply object REST-API Support ApplyObject of non-commit refs Fix 'illegal format conversion' compilation error Extract FetchApiClient interface Add user/password authentication for CGit client Manage removal of refs Use correct placeholders when logging in RevisionReader Honour the fetch ref-spec in replication.config Log the incoming git refs events for replication Fix pull-replication after the removal of Log4J from Gerrit Allow replication of refs that point to non-commit objects Fix the default logic in managing refs-filter Reuse Gerrit code for head update Add HEAD update REST API endpoint Add project initialisation during fetch REST Api call Add project delete REST API endpoint Allow project deletion on replicas Allow project creation only when CREATE_PROJECT capability is set Allow dynamic url creation in tests Handle HEAD update in replicas Trigger remote update HEAD Allow project deletion on primaries Add missing project creation support for replicas Add missing project creation support Change-Id: If4e2540da850ba0cad821c0d074460674f8bc851
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/ApplyObjectMetrics.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/ApplyObjectMetrics.java index 78b6ddb..78745bb 100644 --- a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/ApplyObjectMetrics.java +++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/ApplyObjectMetrics.java
@@ -26,25 +26,33 @@ @Singleton public class ApplyObjectMetrics { private final Timer1<String> executionTime; + private final Timer1<String> end2EndTime; @Inject ApplyObjectMetrics(@PluginName String pluginName, MetricMaker metricMaker) { - Field<String> SOURCE_FIELD = + Field<String> field = Field.ofString( - "source", + "pull_replication", (metadataBuilder, fieldValue) -> metadataBuilder .pluginName(pluginName) - .addPluginMetadata(PluginMetadata.create("source", fieldValue))) + .addPluginMetadata(PluginMetadata.create("pull_replication", fieldValue))) .build(); - executionTime = metricMaker.newTimer( "apply_object_latency", new Description("Time spent applying object from remote source.") .setCumulative() .setUnit(Description.Units.MILLISECONDS), - SOURCE_FIELD); + field); + + end2EndTime = + metricMaker.newTimer( + "apply_object_end_2_end_latency", + new Description("Time spent for e2e replication with the apply object REST API") + .setCumulative() + .setUnit(Description.Units.MILLISECONDS), + field); } /** @@ -56,4 +64,14 @@ public Timer1.Context<String> start(String name) { return executionTime.start(name); } + + /** + * Start the replication latency timer from a source. + * + * @param name the source name. + * @return the timer context. + */ + public Timer1.Context<String> startEnd2End(String name) { + return end2EndTime.start(name); + } }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/DeleteProjectTask.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/DeleteProjectTask.java new file mode 100644 index 0000000..527b746 --- /dev/null +++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/DeleteProjectTask.java
@@ -0,0 +1,79 @@ +// Copyright (C) 2021 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.googlesource.gerrit.plugins.replication.pull; + +import static com.googlesource.gerrit.plugins.replication.pull.ReplicationQueue.repLog; + +import com.google.common.flogger.FluentLogger; +import com.google.gerrit.entities.Project; +import com.google.gerrit.server.ioutil.HexFormat; +import com.google.gerrit.server.util.IdGenerator; +import com.google.inject.Inject; +import com.google.inject.assistedinject.Assisted; +import com.googlesource.gerrit.plugins.replication.pull.client.FetchApiClient; +import com.googlesource.gerrit.plugins.replication.pull.client.HttpResult; +import java.io.IOException; +import java.net.URISyntaxException; +import org.eclipse.jgit.transport.URIish; + +public class DeleteProjectTask implements Runnable { + private static final FluentLogger logger = FluentLogger.forEnclosingClass(); + + interface Factory { + DeleteProjectTask create(Source source, String uri, Project.NameKey project); + } + + private final int id; + private final Source source; + private final String uri; + private final Project.NameKey project; + private final FetchApiClient.Factory fetchClientFactory; + + @Inject + DeleteProjectTask( + FetchApiClient.Factory fetchClientFactory, + IdGenerator ig, + @Assisted Source source, + @Assisted String uri, + @Assisted Project.NameKey project) { + this.fetchClientFactory = fetchClientFactory; + this.id = ig.next(); + this.uri = uri; + this.source = source; + this.project = project; + } + + @Override + public void run() { + try { + URIish urIish = new URIish(uri); + HttpResult httpResult = fetchClientFactory.create(source).deleteProject(project, urIish); + if (!httpResult.isSuccessful()) { + throw new IOException(httpResult.getMessage().orElse("Unknown")); + } + logger.atFine().log("Successfully deleted project %s on remote %s", project.get(), uri); + } catch (URISyntaxException | IOException e) { + String errorMessage = + String.format("Cannot delete project %s on remote site %s.", project, uri); + logger.atWarning().withCause(e).log(errorMessage); + repLog.warn(errorMessage); + } + } + + @Override + public String toString() { + return String.format("[%s] delete-project %s at %s", HexFormat.fromInt(id), project.get(), uri); + } +}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/FetchOne.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/FetchOne.java index d4e22d9..cee52b5 100644 --- a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/FetchOne.java +++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/FetchOne.java
@@ -28,6 +28,7 @@ import com.google.gerrit.server.git.ProjectRunnable; import com.google.gerrit.server.git.WorkQueue.CanceledWhileRunning; import com.google.gerrit.server.ioutil.HexFormat; +import com.google.gerrit.server.logging.TraceContext; import com.google.gerrit.server.util.IdGenerator; import com.google.inject.Inject; import com.google.inject.assistedinject.Assisted; @@ -40,6 +41,7 @@ import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.Set; import java.util.concurrent.Callable; import java.util.concurrent.atomic.AtomicBoolean; @@ -54,7 +56,6 @@ import org.eclipse.jgit.transport.RefSpec; import org.eclipse.jgit.transport.RemoteConfig; import org.eclipse.jgit.transport.URIish; -import org.slf4j.MDC; /** * A pull from remote operation started by command-line. @@ -65,7 +66,7 @@ public class FetchOne implements ProjectRunnable, CanceledWhileRunning { private final ReplicationStateListener stateLog; static final String ALL_REFS = "..all.."; - static final String ID_MDC_KEY = "fetchOneId"; + static final String ID_KEY = "fetchOneId"; interface Factory { FetchOne create(Project.NameKey d, URIish u); @@ -270,11 +271,16 @@ } private void runFetchOperation() { + try (TraceContext ctx = TraceContext.open().addTag(ID_KEY, HexFormat.fromInt(id))) { + doRunFetchOperation(); + } + } + + private void doRunFetchOperation() { // Lock the queue, and remove ourselves, so we can't be modified once // we start replication (instead a new instance, with the same URI, is // created and scheduled for a future point in time.) // - MDC.put(ID_MDC_KEY, HexFormat.fromInt(id)); if (!pool.requestRunway(this)) { if (!canceled) { repLog.info( @@ -368,10 +374,25 @@ } private List<RefSpec> getFetchRefSpecs() { + List<RefSpec> configRefSpecs = config.getFetchRefSpecs(); if (delta.isEmpty()) { - return config.getFetchRefSpecs(); + return configRefSpecs; } - return delta.stream().map(ref -> new RefSpec(ref + ":" + ref)).collect(Collectors.toList()); + + return delta.stream() + .map(ref -> refToFetchRefSpec(ref, configRefSpecs)) + .filter(Optional::isPresent) + .map(Optional::get) + .collect(Collectors.toList()); + } + + private Optional<RefSpec> refToFetchRefSpec(String ref, List<RefSpec> configRefSpecs) { + for (RefSpec refSpec : configRefSpecs) { + if (refSpec.matchSource(ref)) { + return Optional.of(refSpec.expandFromSource(ref)); + } + } + return Optional.empty(); } private void updateStates(List<RefUpdateState> refUpdates) throws IOException {
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/GerritConfigOps.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/GerritConfigOps.java new file mode 100644 index 0000000..2437d80 --- /dev/null +++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/GerritConfigOps.java
@@ -0,0 +1,54 @@ +// Copyright (C) 2021 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.googlesource.gerrit.plugins.replication.pull; + +import com.google.common.flogger.FluentLogger; +import com.google.gerrit.server.config.GerritServerConfig; +import com.google.gerrit.server.config.SitePaths; +import com.google.inject.Inject; +import com.google.inject.Singleton; +import java.net.URISyntaxException; +import java.nio.file.Path; +import java.util.Optional; +import org.eclipse.jgit.lib.Config; +import org.eclipse.jgit.transport.URIish; + +@Singleton +public class GerritConfigOps { + private static final FluentLogger logger = FluentLogger.forEnclosingClass(); + + private final SitePaths sitePath; + private final Config gerritConfig; + + @Inject + public GerritConfigOps(@GerritServerConfig Config cfg, SitePaths sitePath) { + this.sitePath = sitePath; + this.gerritConfig = cfg; + } + + public Optional<URIish> getGitRepositoryURI(String projectName) { + Path basePath = sitePath.resolve(gerritConfig.getString("gerrit", null, "basePath")); + URIish uri; + + try { + uri = new URIish("file://" + basePath + "/" + projectName); + return Optional.of(uri); + } catch (URISyntaxException e) { + logger.atSevere().withCause(e).log("Unsupported URI for project " + projectName); + } + + return Optional.empty(); + } +}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/PullReplicationLogFile.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/PullReplicationLogFile.java index 299648d..932103b 100644 --- a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/PullReplicationLogFile.java +++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/PullReplicationLogFile.java
@@ -28,6 +28,6 @@ systemLog, serverInfo, PullReplicationLogger.PULL_REPLICATION_LOG_NAME, - new PatternLayout("[%d] [%X{" + FetchOne.ID_MDC_KEY + "}] %m%n")); + new PatternLayout("[%d] %m%n")); } }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/PullReplicationModule.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/PullReplicationModule.java index 50ab913..c17d5df 100644 --- a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/PullReplicationModule.java +++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/PullReplicationModule.java
@@ -20,7 +20,9 @@ import com.google.gerrit.extensions.annotations.Exports; import com.google.gerrit.extensions.config.CapabilityDefinition; import com.google.gerrit.extensions.events.GitReferenceUpdatedListener; +import com.google.gerrit.extensions.events.HeadUpdatedListener; import com.google.gerrit.extensions.events.LifecycleListener; +import com.google.gerrit.extensions.events.ProjectDeletedListener; import com.google.gerrit.extensions.registration.DynamicSet; import com.google.gerrit.server.config.SitePaths; import com.google.gerrit.server.events.EventTypes; @@ -41,6 +43,7 @@ import com.googlesource.gerrit.plugins.replication.ReplicationFileBasedConfig; import com.googlesource.gerrit.plugins.replication.StartReplicationCapability; import com.googlesource.gerrit.plugins.replication.pull.api.PullReplicationApiModule; +import com.googlesource.gerrit.plugins.replication.pull.client.FetchApiClient; import com.googlesource.gerrit.plugins.replication.pull.client.FetchRestApiClient; import com.googlesource.gerrit.plugins.replication.pull.client.HttpClient; import com.googlesource.gerrit.plugins.replication.pull.client.SourceHttpClient; @@ -80,7 +83,10 @@ .build(SourceHttpClient.Factory.class)); install(new FactoryModuleBuilder().build(Source.Factory.class)); - install(new FactoryModuleBuilder().build(FetchRestApiClient.Factory.class)); + install( + new FactoryModuleBuilder() + .implement(FetchApiClient.class, FetchRestApiClient.class) + .build(FetchApiClient.Factory.class)); bind(FetchReplicationMetrics.class).in(Scopes.SINGLETON); @@ -101,6 +107,8 @@ bind(EventBus.class).in(Scopes.SINGLETON); bind(ReplicationSources.class).to(SourcesCollection.class); + DynamicSet.bind(binder(), ProjectDeletedListener.class).to(ReplicationQueue.class); + DynamicSet.bind(binder(), HeadUpdatedListener.class).to(ReplicationQueue.class); bind(ReplicationQueue.class).in(Scopes.SINGLETON); bind(ObservableQueue.class).to(ReplicationQueue.class);
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/ReplicationQueue.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/ReplicationQueue.java index 3ded8eb..6393c08 100644 --- a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/ReplicationQueue.java +++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/ReplicationQueue.java
@@ -19,8 +19,11 @@ import com.google.gerrit.entities.Project; import com.google.gerrit.entities.Project.NameKey; import com.google.gerrit.extensions.events.GitReferenceUpdatedListener; +import com.google.gerrit.extensions.events.HeadUpdatedListener; import com.google.gerrit.extensions.events.LifecycleListener; +import com.google.gerrit.extensions.events.ProjectDeletedListener; import com.google.gerrit.extensions.registration.DynamicItem; +import com.google.gerrit.metrics.Timer1.Context; import com.google.gerrit.server.events.EventDispatcher; import com.google.gerrit.server.git.WorkQueue; import com.google.inject.Inject; @@ -29,8 +32,7 @@ import com.googlesource.gerrit.plugins.replication.pull.FetchResultProcessing.GitUpdateProcessing; import com.googlesource.gerrit.plugins.replication.pull.api.data.RevisionData; import com.googlesource.gerrit.plugins.replication.pull.api.exception.MissingParentObjectException; -import com.googlesource.gerrit.plugins.replication.pull.api.exception.RefUpdateException; -import com.googlesource.gerrit.plugins.replication.pull.client.FetchRestApiClient; +import com.googlesource.gerrit.plugins.replication.pull.client.FetchApiClient; import com.googlesource.gerrit.plugins.replication.pull.client.HttpResult; import com.googlesource.gerrit.plugins.replication.pull.filter.ExcludedRefsFilter; import java.io.IOException; @@ -44,6 +46,7 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; import java.util.function.Consumer; +import org.apache.http.client.ClientProtocolException; import org.eclipse.jgit.errors.InvalidObjectIdException; import org.eclipse.jgit.lib.ObjectId; import org.eclipse.jgit.transport.URIish; @@ -51,7 +54,11 @@ import org.slf4j.LoggerFactory; public class ReplicationQueue - implements ObservableQueue, LifecycleListener, GitReferenceUpdatedListener { + implements ObservableQueue, + LifecycleListener, + GitReferenceUpdatedListener, + ProjectDeletedListener, + HeadUpdatedListener { static final String PULL_REPLICATION_LOG_NAME = "pull_replication_log"; static final Logger repLog = LoggerFactory.getLogger(PULL_REPLICATION_LOG_NAME); @@ -65,10 +72,11 @@ private volatile boolean running; private volatile boolean replaying; private final Queue<ReferenceUpdatedEvent> beforeStartupEventsQueue; - private FetchRestApiClient.Factory fetchClientFactory; + private FetchApiClient.Factory fetchClientFactory; private Integer fetchCallsTimeout; private ExcludedRefsFilter refsFilter; private RevisionReader revisionReader; + private final ApplyObjectMetrics applyObjectMetrics; @Inject ReplicationQueue( @@ -76,9 +84,10 @@ Provider<SourcesCollection> rd, DynamicItem<EventDispatcher> dis, ReplicationStateListeners sl, - FetchRestApiClient.Factory fetchClientFactory, + FetchApiClient.Factory fetchClientFactory, ExcludedRefsFilter refsFilter, - RevisionReader revReader) { + RevisionReader revReader, + ApplyObjectMetrics applyObjectMetrics) { workQueue = wq; dispatcher = dis; sources = rd; @@ -87,6 +96,7 @@ this.fetchClientFactory = fetchClientFactory; this.refsFilter = refsFilter; this.revisionReader = revReader; + this.applyObjectMetrics = applyObjectMetrics; } @Override @@ -127,7 +137,39 @@ @Override public void onGitReferenceUpdated(GitReferenceUpdatedListener.Event event) { if (isRefToBeReplicated(event.getRefName())) { - fire(event.getProjectName(), ObjectId.fromString(event.getNewObjectId()), event.getRefName()); + repLog.info( + "Ref event received: {} on project {}:{} - {} => {}", + refUpdateType(event), + event.getProjectName(), + event.getRefName(), + event.getOldObjectId(), + event.getNewObjectId()); + fire( + event.getProjectName(), + ObjectId.fromString(event.getNewObjectId()), + event.getRefName(), + event.isDelete()); + } + } + + @Override + public void onProjectDeleted(ProjectDeletedListener.Event event) { + Project.NameKey project = Project.nameKey(event.getProjectName()); + sources.get().getAll().stream() + .filter((Source s) -> s.wouldDeleteProject(project)) + .forEach( + source -> + source.getApis().forEach(apiUrl -> source.scheduleDeleteProject(apiUrl, project))); + } + + private static String refUpdateType(GitReferenceUpdatedListener.Event event) { + String forcedPrefix = event.isNonFastForward() ? "FORCED " : " "; + if (event.isCreate()) { + return forcedPrefix + "CREATE"; + } else if (event.isDelete()) { + return forcedPrefix + "DELETE"; + } else { + return forcedPrefix + "UPDATE"; } } @@ -135,26 +177,32 @@ return !refsFilter.match(refName); } - private void fire(String projectName, ObjectId objectId, String refName) { + private void fire(String projectName, ObjectId objectId, String refName, boolean isDelete) { ReplicationState state = new ReplicationState(new GitUpdateProcessing(dispatcher.get())); - fire(Project.nameKey(projectName), objectId, refName, state); + fire(Project.nameKey(projectName), objectId, refName, isDelete, state); state.markAllFetchTasksScheduled(); } private void fire( - Project.NameKey project, ObjectId objectId, String refName, ReplicationState state) { + Project.NameKey project, + ObjectId objectId, + String refName, + boolean isDelete, + ReplicationState state) { if (!running) { stateLog.warn( "Replication plugin did not finish startup before event, event replication is postponed", state); - beforeStartupEventsQueue.add(ReferenceUpdatedEvent.create(project.get(), refName, objectId)); + beforeStartupEventsQueue.add( + ReferenceUpdatedEvent.create(project.get(), refName, objectId, isDelete)); return; } ForkJoinPool fetchCallsPool = null; try { fetchCallsPool = new ForkJoinPool(sources.get().getAll().size()); - final Consumer<Source> callFunction = callFunction(project, refName, state); + final Consumer<Source> callFunction = + callFunction(project, objectId, refName, isDelete, state); fetchCallsPool .submit(() -> sources.get().getAll().parallelStream().forEach(callFunction)) .get(fetchCallsTimeout, TimeUnit.MILLISECONDS); @@ -172,8 +220,13 @@ } } - private Consumer<Source> callFunction(NameKey project, String refName, ReplicationState state) { - CallFunction call = getCallFunction(project, refName, state); + private Consumer<Source> callFunction( + NameKey project, + ObjectId objectId, + String refName, + boolean isDelete, + ReplicationState state) { + CallFunction call = getCallFunction(project, objectId, refName, isDelete, state); return (source) -> { try { @@ -184,13 +237,29 @@ }; } - private CallFunction getCallFunction(NameKey project, String refName, ReplicationState state) { + private CallFunction getCallFunction( + NameKey project, + ObjectId objectId, + String refName, + boolean isDelete, + ReplicationState state) { + if (isDelete) { + return ((source) -> callSendObject(source, project, refName, isDelete, null, state)); + } + try { - Optional<RevisionData> revisionData = revisionReader.read(project, refName); + Optional<RevisionData> revisionData = revisionReader.read(project, objectId, refName); + repLog.info( + "RevisionData is {} for {}:{}", + revisionData.map(RevisionData::toString).orElse("ABSENT"), + project, + refName); + if (revisionData.isPresent()) { - return ((source) -> callSendObject(source, project, refName, revisionData.get(), state)); + return ((source) -> + callSendObject(source, project, refName, isDelete, revisionData.get(), state)); } - } catch (InvalidObjectIdException | IOException | RefUpdateException e) { + } catch (InvalidObjectIdException | IOException e) { stateLog.error( String.format( "Exception during reading ref: %s, project:%s, message: %s", @@ -206,6 +275,7 @@ Source source, Project.NameKey project, String refName, + boolean isDelete, RevisionData revision, ReplicationState state) throws MissingParentObjectException { @@ -213,25 +283,57 @@ for (String apiUrl : source.getApis()) { try { URIish uri = new URIish(apiUrl); - FetchRestApiClient fetchClient = fetchClientFactory.create(source); + FetchApiClient fetchClient = fetchClientFactory.create(source); + repLog.info( + "Pull replication REST API apply object to {} for {}:{} - {}", + apiUrl, + project, + refName, + revision); + Context<String> apiTimer = applyObjectMetrics.startEnd2End(source.getRemoteConfigName()); + HttpResult result = fetchClient.callSendObject(project, refName, isDelete, revision, uri); + repLog.info( + "Pull replication REST API apply object to {} COMPLETED for {}:{} - {}, HTTP Result:" + + " {} - time:{} ms", + apiUrl, + project, + refName, + revision, + result, + apiTimer.stop() / 1000000.0); - HttpResult result = fetchClient.callSendObject(project, refName, revision, uri); + if (isProjectMissing(result, project) && source.isCreateMissingRepositories()) { + result = initProject(project, uri, fetchClient, result); + repLog.info("Missing project {} created, HTTP Result:{}", project, result); + } + if (!result.isSuccessful()) { - repLog.warn( - String.format( - "Pull replication rest api apply object call failed. Endpoint url: %s, reason:%s", - apiUrl, result.getMessage().orElse("unknown"))); if (result.isParentObjectMissing()) { throw new MissingParentObjectException( project, refName, source.getRemoteConfigName()); } } } catch (URISyntaxException e) { + repLog.warn( + "Pull replication REST API apply object to {} *FAILED* for {}:{} - {}", + apiUrl, + project, + refName, + revision, + e); stateLog.error(String.format("Cannot parse pull replication api url:%s", apiUrl), state); } catch (IOException e) { + repLog.warn( + "Pull replication REST API apply object to {} *FAILED* for {}:{} - {}", + apiUrl, + project, + refName, + revision, + e); stateLog.error( String.format( - "Exception during the pull replication fetch rest api call. Endpoint url:%s, message:%s", + "Exception during the pull replication fetch rest api call. Endpoint url:%s," + + " message:%s", apiUrl, e.getMessage()), e, state); @@ -246,9 +348,11 @@ for (String apiUrl : source.getApis()) { try { URIish uri = new URIish(apiUrl); - FetchRestApiClient fetchClient = fetchClientFactory.create(source); - + FetchApiClient fetchClient = fetchClientFactory.create(source); HttpResult result = fetchClient.callFetch(project, refName, uri); + if (isProjectMissing(result, project) && source.isCreateMissingRepositories()) { + result = initProject(project, uri, fetchClient, result); + } if (!result.isSuccessful()) { stateLog.warn( String.format( @@ -261,7 +365,8 @@ } catch (Exception e) { stateLog.error( String.format( - "Exception during the pull replication fetch rest api call. Endpoint url:%s, message:%s", + "Exception during the pull replication fetch rest api call. Endpoint url:%s," + + " message:%s", apiUrl, e.getMessage()), e, state); @@ -274,23 +379,53 @@ return maxRetries == 0 || attempt < maxRetries; } + private Boolean isProjectMissing(HttpResult result, Project.NameKey project) { + return !result.isSuccessful() && result.isProjectMissing(project); + } + + private HttpResult initProject( + Project.NameKey project, URIish uri, FetchApiClient fetchClient, HttpResult result) + throws IOException, ClientProtocolException { + HttpResult initProjectResult = fetchClient.initProject(project, uri); + if (initProjectResult.isSuccessful()) { + result = fetchClient.callFetch(project, "refs/*", uri); + } else { + String errorMessage = initProjectResult.getMessage().map(e -> " - Error: " + e).orElse(""); + repLog.error("Cannot create project " + project + errorMessage); + } + return result; + } + private void fireBeforeStartupEvents() { Set<String> eventsReplayed = new HashSet<>(); for (ReferenceUpdatedEvent event : beforeStartupEventsQueue) { String eventKey = String.format("%s:%s", event.projectName(), event.refName()); if (!eventsReplayed.contains(eventKey)) { repLog.info("Firing pending task {}", event); - fire(event.projectName(), event.objectId(), event.refName()); + fire(event.projectName(), event.objectId(), event.refName(), event.isDelete()); eventsReplayed.add(eventKey); } } } + @Override + public void onHeadUpdated(HeadUpdatedListener.Event event) { + Project.NameKey p = Project.nameKey(event.getProjectName()); + sources.get().getAll().stream() + .filter(s -> s.wouldFetchProject(p)) + .forEach( + s -> + s.getApis() + .forEach(apiUrl -> s.scheduleUpdateHead(apiUrl, p, event.getNewHeadName()))); + } + @AutoValue abstract static class ReferenceUpdatedEvent { - static ReferenceUpdatedEvent create(String projectName, String refName, ObjectId objectId) { - return new AutoValue_ReplicationQueue_ReferenceUpdatedEvent(projectName, refName, objectId); + static ReferenceUpdatedEvent create( + String projectName, String refName, ObjectId objectId, boolean isDelete) { + return new AutoValue_ReplicationQueue_ReferenceUpdatedEvent( + projectName, refName, objectId, isDelete); } public abstract String projectName(); @@ -298,6 +433,8 @@ public abstract String refName(); public abstract ObjectId objectId(); + + public abstract boolean isDelete(); } @FunctionalInterface
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/RevisionReader.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/RevisionReader.java index 5718249..92cbd95 100644 --- a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/RevisionReader.java +++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/RevisionReader.java
@@ -17,14 +17,15 @@ import static com.googlesource.gerrit.plugins.replication.pull.PullReplicationLogger.repLog; import com.google.common.collect.Lists; +import com.google.gerrit.common.Nullable; import com.google.gerrit.entities.Project; import com.google.gerrit.server.git.GitRepositoryManager; import com.google.inject.Inject; import com.googlesource.gerrit.plugins.replication.ReplicationConfig; import com.googlesource.gerrit.plugins.replication.pull.api.data.RevisionData; import com.googlesource.gerrit.plugins.replication.pull.api.data.RevisionObjectData; -import com.googlesource.gerrit.plugins.replication.pull.api.exception.RefUpdateException; import java.io.IOException; +import java.util.Arrays; import java.util.List; import java.util.Optional; import org.eclipse.jgit.diff.DiffEntry; @@ -34,6 +35,7 @@ import org.eclipse.jgit.errors.LargeObjectException; import org.eclipse.jgit.errors.MissingObjectException; import org.eclipse.jgit.errors.RepositoryNotFoundException; +import org.eclipse.jgit.lib.Constants; import org.eclipse.jgit.lib.ObjectId; import org.eclipse.jgit.lib.ObjectLoader; import org.eclipse.jgit.lib.Ref; @@ -58,21 +60,47 @@ } public Optional<RevisionData> read(Project.NameKey project, String refName) - throws MissingObjectException, IncorrectObjectTypeException, CorruptObjectException, - RepositoryNotFoundException, RefUpdateException, IOException { - try (Repository git = gitRepositoryManager.openRepository(project)) { - Ref head = git.exactRef(refName); - if (head == null) { - throw new RefUpdateException( - String.format("Cannot find ref %s in project %s", refName, project.get())); - } + throws RepositoryNotFoundException, MissingObjectException, IncorrectObjectTypeException, + CorruptObjectException, IOException { + return read(project, null, refName); + } + public Optional<RevisionData> read( + Project.NameKey project, @Nullable ObjectId refObjectId, String refName) + throws MissingObjectException, IncorrectObjectTypeException, CorruptObjectException, + RepositoryNotFoundException, IOException { + try (Repository git = gitRepositoryManager.openRepository(project)) { Long totalRefSize = 0l; - ObjectLoader commitLoader = git.open(head.getObjectId()); + Ref ref = git.exactRef(refName); + if (ref == null) { + return Optional.empty(); + } + + ObjectId objectId = refObjectId == null ? ref.getObjectId() : refObjectId; + + ObjectLoader commitLoader = git.open(objectId); totalRefSize += commitLoader.getSize(); verifySize(totalRefSize, commitLoader); + if (commitLoader.getType() == Constants.OBJ_BLOB) { + return Optional.of( + new RevisionData( + null, + null, + Arrays.asList( + new RevisionObjectData(Constants.OBJ_BLOB, commitLoader.getCachedBytes())))); + } + + if (commitLoader.getType() != Constants.OBJ_COMMIT) { + repLog.trace( + "Ref {} for project {} points to an object type {}", + refName, + project, + commitLoader.getType()); + return Optional.empty(); + } + RevCommit commit = RevCommit.parse(commitLoader.getCachedBytes()); RevisionObjectData commitRev = new RevisionObjectData(commit.getType(), commitLoader.getCachedBytes()); @@ -99,8 +127,10 @@ return Optional.of(new RevisionData(commitRev, treeRev, blobs)); } catch (LargeObjectException e) { repLog.trace( - "Ref %s size for project %s is greater than configured '%s'", - refName, project, CONFIG_MAX_API_PAYLOAD_SIZE); + "Ref {} size for project {} is greater than configured '{}'", + refName, + project, + CONFIG_MAX_API_PAYLOAD_SIZE); return Optional.empty(); } }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/Source.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/Source.java index 20d9aba..ddefa47 100644 --- a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/Source.java +++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/Source.java
@@ -70,6 +70,7 @@ import com.googlesource.gerrit.plugins.replication.pull.fetch.JGitFetch; import java.io.IOException; import java.io.UnsupportedEncodingException; +import java.net.URISyntaxException; import java.net.URLEncoder; import java.util.HashMap; import java.util.List; @@ -80,6 +81,7 @@ import java.util.concurrent.CompletableFuture; import java.util.concurrent.Future; import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; import java.util.function.Supplier; import org.apache.commons.io.FilenameUtils; @@ -101,6 +103,7 @@ } private final ReplicationStateListener stateLog; + private final UpdateHeadTask.Factory updateHeadFactory; private final Object stateLock = new Object(); private final Map<URIish, FetchOne> pending = new HashMap<>(); private final Map<URIish, FetchOne> inFlight = new HashMap<>(); @@ -114,6 +117,7 @@ private final SourceConfiguration config; private final DynamicItem<EventDispatcher> eventDispatcher; private CloseableHttpClient httpClient; + private final DeleteProjectTask.Factory deleteProjectFactory; protected enum RetryReason { TRANSPORT_ERROR, @@ -180,6 +184,7 @@ bind(Source.class).toInstance(Source.this); bind(SourceConfiguration.class).toInstance(config); install(new FactoryModuleBuilder().build(FetchOne.Factory.class)); + install(new FactoryModuleBuilder().build(DeleteProjectTask.Factory.class)); Class<? extends Fetch> clientClass = cfg.useCGitClient() ? CGitFetch.class : JGitFetch.class; install( @@ -187,6 +192,7 @@ .implement(Fetch.class, BatchFetchClient.class) .implement(Fetch.class, FetchClientImplementation.class, clientClass) .build(FetchFactory.class)); + factory(UpdateHeadTask.Factory.class); } @Provides @@ -210,6 +216,8 @@ child.getBinding(FetchFactory.class).acceptTargetVisitor(new CGitFetchValidator()); opFactory = child.getInstance(FetchOne.Factory.class); threadScoper = child.getInstance(PerThreadRequestScope.Scoper.class); + deleteProjectFactory = child.getInstance(DeleteProjectTask.Factory.class); + updateHeadFactory = child.getInstance(UpdateHeadTask.Factory.class); } public synchronized CloseableHttpClient memoize( @@ -445,6 +453,12 @@ } } + void scheduleDeleteProject(String uri, Project.NameKey project) { + @SuppressWarnings("unused") + ScheduledFuture<?> ignored = + pool.schedule(deleteProjectFactory.create(this, uri, project), 0, TimeUnit.SECONDS); + } + void fetchWasCanceled(FetchOne fetchOp) { synchronized (stateLock) { URIish uri = fetchOp.getURI(); @@ -592,11 +606,22 @@ return false; } + public boolean wouldDeleteProject(Project.NameKey project) { + if (isReplicateProjectDeletions()) { + return configSettingsAllowReplication(project); + } + return false; + } + public boolean wouldFetchProject(Project.NameKey project) { if (!shouldReplicate(project)) { return false; } + return configSettingsAllowReplication(project); + } + + private boolean configSettingsAllowReplication(Project.NameKey project) { // by default fetch all projects List<String> projects = config.getProjects(); if (projects.isEmpty()) { @@ -733,6 +758,27 @@ return config.getMaxRetries(); } + public boolean isCreateMissingRepositories() { + return config.createMissingRepositories(); + } + + public boolean isReplicateProjectDeletions() { + return config.replicateProjectDeletions(); + } + + void scheduleUpdateHead(String apiUrl, Project.NameKey project, String newHead) { + try { + URIish apiURI = new URIish(apiUrl); + @SuppressWarnings("unused") + ScheduledFuture<?> ignored = + pool.schedule( + updateHeadFactory.create(this, apiURI, project, newHead), 0, TimeUnit.SECONDS); + } catch (URISyntaxException e) { + logger.atSevere().withCause(e).log( + "Could not schedule HEAD pull-replication for project %s", project.get()); + } + } + private static boolean matches(URIish uri, String urlMatch) { if (urlMatch == null || urlMatch.equals("") || urlMatch.equals("*")) { return true;
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/SourceConfigParser.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/SourceConfigParser.java index 6dfed44..a8799c2 100644 --- a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/SourceConfigParser.java +++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/SourceConfigParser.java
@@ -35,6 +35,7 @@ /* (non-Javadoc) * @see com.googlesource.gerrit.plugins.replication.ConfigParser#parseRemotes(org.eclipse.jgit.lib.Config) */ + @Override public List<RemoteConfiguration> parseRemotes(Config config) throws ConfigInvalidException { if (config.getSections().isEmpty()) {
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/SourceConfiguration.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/SourceConfiguration.java index 8046d49..ab9c634 100644 --- a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/SourceConfiguration.java +++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/SourceConfiguration.java
@@ -38,6 +38,8 @@ private final int poolThreads; private final boolean replicatePermissions; private final boolean replicateHiddenProjects; + private final boolean createMissingRepositories; + private final boolean replicateProjectDeletions; private final String remoteNameStyle; private final ImmutableList<String> urls; private final ImmutableList<String> projects; @@ -74,6 +76,8 @@ authGroupNames = ImmutableList.copyOf(cfg.getStringList("remote", name, "authGroup")); lockErrorMaxRetries = cfg.getInt("replication", "lockErrorMaxRetries", 0); + createMissingRepositories = cfg.getBoolean("remote", name, "createMissingRepositories", true); + replicateProjectDeletions = cfg.getBoolean("remote", name, "replicateProjectDeletions", true); replicatePermissions = cfg.getBoolean("remote", name, "replicatePermissions", true); replicateHiddenProjects = cfg.getBoolean("remote", name, "replicateHiddenProjects", false); useCGitClient = cfg.getBoolean("replication", "useCGitClient", false); @@ -191,6 +195,14 @@ return maxRetries; } + public boolean createMissingRepositories() { + return createMissingRepositories; + } + + public boolean replicateProjectDeletions() { + return replicateProjectDeletions; + } + private static int getInt(RemoteConfig rc, Config cfg, String name, int defValue) { return cfg.getInt("remote", rc.getName(), name, defValue); }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/UpdateHeadTask.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/UpdateHeadTask.java new file mode 100644 index 0000000..e169eb3 --- /dev/null +++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/UpdateHeadTask.java
@@ -0,0 +1,86 @@ +// Copyright (C) 2021 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.googlesource.gerrit.plugins.replication.pull; + +import static com.googlesource.gerrit.plugins.replication.pull.ReplicationQueue.repLog; + +import com.google.common.flogger.FluentLogger; +import com.google.gerrit.entities.Project; +import com.google.gerrit.server.ioutil.HexFormat; +import com.google.gerrit.server.util.IdGenerator; +import com.google.inject.Inject; +import com.google.inject.assistedinject.Assisted; +import com.googlesource.gerrit.plugins.replication.pull.client.FetchApiClient; +import com.googlesource.gerrit.plugins.replication.pull.client.HttpResult; +import java.io.IOException; +import org.eclipse.jgit.transport.URIish; + +public class UpdateHeadTask implements Runnable { + private static final FluentLogger logger = FluentLogger.forEnclosingClass(); + private final FetchApiClient.Factory fetchClientFactory; + private final Source source; + private final URIish apiURI; + private final Project.NameKey project; + private final String newHead; + private final int id; + + interface Factory { + UpdateHeadTask create(Source source, URIish apiURI, Project.NameKey project, String newHead); + } + + @Inject + UpdateHeadTask( + FetchApiClient.Factory fetchClientFactory, + IdGenerator ig, + @Assisted Source source, + @Assisted URIish apiURI, + @Assisted Project.NameKey project, + @Assisted String newHead) { + this.fetchClientFactory = fetchClientFactory; + this.id = ig.next(); + this.source = source; + this.apiURI = apiURI; + this.project = project; + this.newHead = newHead; + } + + @Override + public void run() { + try { + HttpResult httpResult = + fetchClientFactory.create(source).updateHead(project, newHead, apiURI); + if (!httpResult.isSuccessful()) { + throw new IOException(httpResult.getMessage().orElse("Unknown")); + } + logger.atFine().log( + "Successfully updated HEAD of project %s on remote %s", + project.get(), apiURI.toASCIIString()); + } catch (IOException e) { + String errorMessage = + String.format( + "Cannot update HEAD of project %s remote site %s", + project.get(), apiURI.toASCIIString()); + logger.atWarning().withCause(e).log(errorMessage); + repLog.warn(errorMessage); + } + } + + @Override + public String toString() { + return String.format( + "[%s] update-head %s at %s to %s", + HexFormat.fromInt(id), project.get(), apiURI.toASCIIString(), newHead); + } +}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/ApplyObjectAction.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/ApplyObjectAction.java index f029834..442f585 100644 --- a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/ApplyObjectAction.java +++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/ApplyObjectAction.java
@@ -14,6 +14,8 @@ package com.googlesource.gerrit.plugins.replication.pull.api; +import static com.googlesource.gerrit.plugins.replication.pull.PullReplicationLogger.repLog; + import com.google.common.base.Strings; import com.google.gerrit.extensions.restapi.AuthException; import com.google.gerrit.extensions.restapi.BadRequestException; @@ -33,11 +35,16 @@ public class ApplyObjectAction implements RestModifyView<ProjectResource, RevisionInput> { private final ApplyObjectCommand command; + private final DeleteRefCommand deleteRefCommand; private final FetchPreconditions preConditions; @Inject - public ApplyObjectAction(ApplyObjectCommand command, FetchPreconditions preConditions) { + public ApplyObjectAction( + ApplyObjectCommand command, + DeleteRefCommand deleteRefCommand, + FetchPreconditions preConditions) { this.command = command; + this.deleteRefCommand = deleteRefCommand; this.preConditions = preConditions; } @@ -55,31 +62,68 @@ throw new BadRequestException("Ref-update refname cannot be null or empty"); } + repLog.info( + "Apply object API from {} for {}:{} - {}", + resource.getNameKey(), + input.getLabel(), + input.getRefName(), + input.getRevisionData()); + if (Objects.isNull(input.getRevisionData())) { - throw new BadRequestException("Ref-update revision data cannot be null or empty"); + deleteRefCommand.deleteRef(resource.getNameKey(), input.getRefName(), input.getLabel()); + repLog.info( + "Apply object API - REF DELETED - from {} for {}:{} - {}", + resource.getNameKey(), + input.getLabel(), + input.getRefName(), + input.getRevisionData()); + return Response.created(input); } - if (Objects.isNull(input.getRevisionData().getCommitObject()) - || Objects.isNull(input.getRevisionData().getCommitObject().getContent()) - || input.getRevisionData().getCommitObject().getContent().length == 0 - || Objects.isNull(input.getRevisionData().getCommitObject().getType())) { - throw new BadRequestException("Ref-update commit object cannot be null or empty"); - } - - if (Objects.isNull(input.getRevisionData().getTreeObject()) - || Objects.isNull(input.getRevisionData().getTreeObject().getContent()) - || Objects.isNull(input.getRevisionData().getTreeObject().getType())) { - throw new BadRequestException("Ref-update tree object cannot be null"); + try { + input.validate(); + } catch (IllegalArgumentException e) { + BadRequestException bre = + new BadRequestException("Ref-update with invalid input: " + e.getMessage(), e); + repLog.error( + "Apply object API *FAILED* from {} for {}:{} - {}", + input.getLabel(), + resource.getNameKey(), + input.getRefName(), + input.getRevisionData(), + bre); + throw bre; } command.applyObject( resource.getNameKey(), input.getRefName(), input.getRevisionData(), input.getLabel()); return Response.created(input); } catch (MissingParentObjectException e) { + repLog.error( + "Apply object API *FAILED* from {} for {}:{} - {}", + input.getLabel(), + resource.getNameKey(), + input.getRefName(), + input.getRevisionData(), + e); throw new ResourceConflictException(e.getMessage(), e); } catch (NumberFormatException | IOException e) { + repLog.error( + "Apply object API *FAILED* from {} for {}:{} - {}", + input.getLabel(), + resource.getNameKey(), + input.getRefName(), + input.getRevisionData(), + e); throw RestApiException.wrap(e.getMessage(), e); } catch (RefUpdateException e) { + repLog.error( + "Apply object API *FAILED* from {} for {}:{} - {}", + input.getLabel(), + resource.getNameKey(), + input.getRefName(), + input.getRevisionData(), + e); throw new UnprocessableEntityException(e.getMessage()); } }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/ApplyObjectCommand.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/ApplyObjectCommand.java index 276aa16..ff231d8 100644 --- a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/ApplyObjectCommand.java +++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/ApplyObjectCommand.java
@@ -73,7 +73,7 @@ Project.NameKey name, String refName, RevisionData revisionData, String sourceLabel) throws IOException, RefUpdateException, MissingParentObjectException { - repLog.info("Apply object from {} for project {}, ref name {}", sourceLabel, name, refName); + repLog.info("Apply object from {} for {}:{} - {}", sourceLabel, name, refName, revisionData); Timer1.Context<String> context = metrics.start(sourceLabel); RefUpdateState refUpdateState = applyObject.apply(name, new RefSpec(refName), revisionData); long elapsed = NANOSECONDS.toMillis(context.stop());
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/DeleteRefCommand.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/DeleteRefCommand.java new file mode 100644 index 0000000..0c5e016 --- /dev/null +++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/DeleteRefCommand.java
@@ -0,0 +1,113 @@ +// Copyright (C) 2022 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.googlesource.gerrit.plugins.replication.pull.api; + +import static com.googlesource.gerrit.plugins.replication.pull.PullReplicationLogger.repLog; + +import com.google.common.flogger.FluentLogger; +import com.google.gerrit.entities.Project; +import com.google.gerrit.extensions.registration.DynamicItem; +import com.google.gerrit.extensions.restapi.ResourceConflictException; +import com.google.gerrit.extensions.restapi.ResourceNotFoundException; +import com.google.gerrit.extensions.restapi.RestApiException; +import com.google.gerrit.server.events.EventDispatcher; +import com.google.gerrit.server.permissions.PermissionBackendException; +import com.google.gerrit.server.project.ProjectCache; +import com.google.gerrit.server.project.ProjectState; +import com.google.gerrit.server.restapi.project.DeleteRef; +import com.google.inject.Inject; +import com.googlesource.gerrit.plugins.replication.pull.Context; +import com.googlesource.gerrit.plugins.replication.pull.FetchRefReplicatedEvent; +import com.googlesource.gerrit.plugins.replication.pull.PullReplicationStateLogger; +import com.googlesource.gerrit.plugins.replication.pull.ReplicationState; +import java.io.IOException; +import java.util.Optional; +import org.eclipse.jgit.lib.RefUpdate; + +public class DeleteRefCommand { + private static final FluentLogger logger = FluentLogger.forEnclosingClass(); + + private final PullReplicationStateLogger fetchStateLog; + private final DeleteRef deleteRef; + private final DynamicItem<EventDispatcher> eventDispatcher; + private final ProjectCache projectCache; + + @Inject + public DeleteRefCommand( + PullReplicationStateLogger fetchStateLog, + ProjectCache projectCache, + DeleteRef deleteRef, + DynamicItem<EventDispatcher> eventDispatcher) { + this.fetchStateLog = fetchStateLog; + this.projectCache = projectCache; + this.deleteRef = deleteRef; + this.eventDispatcher = eventDispatcher; + } + + public void deleteRef(Project.NameKey name, String refName, String sourceLabel) + throws IOException, RestApiException { + try { + repLog.info("Delete ref from {} for project {}, ref name {}", sourceLabel, name, refName); + Optional<ProjectState> projectState = projectCache.get(name); + if (!projectState.isPresent()) { + throw new ResourceNotFoundException(String.format("Project %s was not found", name)); + } + + try { + Context.setLocalEvent(true); + deleteRef.deleteSingleRef(projectState.get(), refName); + + eventDispatcher + .get() + .postEvent( + new FetchRefReplicatedEvent( + name.get(), + refName, + sourceLabel, + ReplicationState.RefFetchResult.SUCCEEDED, + RefUpdate.Result.FORCED)); + } catch (PermissionBackendException e) { + logger.atSevere().withCause(e).log( + "Unexpected error while trying to delete ref '%s' on project %s and notifying it", + refName, name); + throw RestApiException.wrap(e.getMessage(), e); + } catch (ResourceConflictException e) { + eventDispatcher + .get() + .postEvent( + new FetchRefReplicatedEvent( + name.get(), + refName, + sourceLabel, + ReplicationState.RefFetchResult.FAILED, + RefUpdate.Result.LOCK_FAILURE)); + String message = + String.format( + "RefUpdate lock failure for: sourceLabel=%s, project=%s, refName=%s", + sourceLabel, name, refName); + logger.atSevere().withCause(e).log(message); + fetchStateLog.error(message); + throw e; + } finally { + Context.unsetLocalEvent(); + } + + repLog.info( + "Delete ref from {} for project {}, ref name {} completed", sourceLabel, name, refName); + } catch (PermissionBackendException e) { + throw RestApiException.wrap(e.getMessage(), e); + } + } +}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/HttpModule.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/HttpModule.java index 7ae7d54..b2ef28d 100644 --- a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/HttpModule.java +++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/HttpModule.java
@@ -35,6 +35,8 @@ DynamicSet.bind(binder(), AllRequestFilter.class) .to(PullReplicationFilter.class) .in(Scopes.SINGLETON); + } else { + serveRegex("/init-project/.*$").with(ProjectInitializationAction.class); } } }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/HttpServletOps.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/HttpServletOps.java new file mode 100644 index 0000000..b424555 --- /dev/null +++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/HttpServletOps.java
@@ -0,0 +1,55 @@ +// Copyright (C) 2021 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.googlesource.gerrit.plugins.replication.pull.api; + +import static com.google.common.net.HttpHeaders.ACCEPT; +import static org.eclipse.jgit.util.HttpSupport.TEXT_PLAIN; + +import com.google.common.net.MediaType; +import java.io.IOException; +import java.io.PrintWriter; +import java.util.Arrays; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +class HttpServletOps { + + static boolean checkAcceptHeader(HttpServletRequest req, HttpServletResponse rsp) + throws IOException { + if (req.getHeader(ACCEPT) == null + || (req.getHeader(ACCEPT) != null + && !Arrays.asList( + MediaType.PLAIN_TEXT_UTF_8.toString(), + MediaType.ANY_TEXT_TYPE.toString(), + MediaType.ANY_TYPE.toString()) + .contains(req.getHeader(ACCEPT)))) { + setResponse( + rsp, + HttpServletResponse.SC_BAD_REQUEST, + "No advertised 'Accept' headers can be honoured. 'text/plain' should be provided in the request 'Accept' header."); + return false; + } + + return true; + } + + static void setResponse(HttpServletResponse httpResponse, int statusCode, String value) + throws IOException { + httpResponse.setContentType(TEXT_PLAIN); + httpResponse.setStatus(statusCode); + PrintWriter writer = httpResponse.getWriter(); + writer.print(value); + } +}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/ProjectDeletionAction.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/ProjectDeletionAction.java new file mode 100644 index 0000000..8915e78 --- /dev/null +++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/ProjectDeletionAction.java
@@ -0,0 +1,68 @@ +// Copyright (C) 2021 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.googlesource.gerrit.plugins.replication.pull.api; + +import com.google.gerrit.extensions.api.access.PluginPermission; +import com.google.gerrit.extensions.restapi.AuthException; +import com.google.gerrit.extensions.restapi.BadRequestException; +import com.google.gerrit.extensions.restapi.ResourceConflictException; +import com.google.gerrit.extensions.restapi.ResourceNotFoundException; +import com.google.gerrit.extensions.restapi.Response; +import com.google.gerrit.extensions.restapi.RestModifyView; +import com.google.gerrit.extensions.restapi.UnprocessableEntityException; +import com.google.gerrit.server.permissions.PermissionBackend; +import com.google.gerrit.server.project.ProjectResource; +import com.google.inject.Inject; +import com.googlesource.gerrit.plugins.replication.LocalFS; +import com.googlesource.gerrit.plugins.replication.pull.GerritConfigOps; +import java.util.Optional; +import org.eclipse.jgit.transport.URIish; + +class ProjectDeletionAction + implements RestModifyView<ProjectResource, ProjectDeletionAction.DeleteInput> { + private static final PluginPermission DELETE_PROJECT = + new PluginPermission("delete-project", "deleteProject"); + + static class DeleteInput {} + + private final GerritConfigOps gerritConfigOps; + private final PermissionBackend permissionBackend; + + @Inject + ProjectDeletionAction(GerritConfigOps gerritConfigOps, PermissionBackend permissionBackend) { + this.gerritConfigOps = gerritConfigOps; + this.permissionBackend = permissionBackend; + } + + @Override + public Response<?> apply(ProjectResource projectResource, DeleteInput input) + throws AuthException, BadRequestException, ResourceConflictException, Exception { + + permissionBackend.user(projectResource.getUser()).check(DELETE_PROJECT); + + Optional<URIish> maybeRepoURI = + gerritConfigOps.getGitRepositoryURI(String.format("%s.git", projectResource.getName())); + + if (maybeRepoURI.isPresent()) { + if (new LocalFS(maybeRepoURI.get()).deleteProject(projectResource.getNameKey())) { + return Response.ok(); + } + throw new UnprocessableEntityException( + String.format("Could not delete project %s", projectResource.getName())); + } + throw new ResourceNotFoundException( + String.format("Could not compute URI for repo: %s", projectResource.getName())); + } +}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/ProjectInitializationAction.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/ProjectInitializationAction.java new file mode 100644 index 0000000..3426c03 --- /dev/null +++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/ProjectInitializationAction.java
@@ -0,0 +1,124 @@ +// Copyright (C) 2021 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.googlesource.gerrit.plugins.replication.pull.api; + +import static com.googlesource.gerrit.plugins.replication.pull.api.FetchApiCapability.CALL_FETCH_ACTION; +import static com.googlesource.gerrit.plugins.replication.pull.api.HttpServletOps.checkAcceptHeader; +import static com.googlesource.gerrit.plugins.replication.pull.api.HttpServletOps.setResponse; + +import com.google.common.flogger.FluentLogger; +import com.google.gerrit.entities.Project; +import com.google.gerrit.entities.RefNames; +import com.google.gerrit.extensions.restapi.AuthException; +import com.google.gerrit.extensions.restapi.Url; +import com.google.gerrit.server.CurrentUser; +import com.google.gerrit.server.permissions.GlobalPermission; +import com.google.gerrit.server.permissions.PermissionBackend; +import com.google.gerrit.server.permissions.PermissionBackendException; +import com.google.inject.Inject; +import com.google.inject.Provider; +import com.google.inject.Singleton; +import com.googlesource.gerrit.plugins.replication.LocalFS; +import com.googlesource.gerrit.plugins.replication.pull.GerritConfigOps; +import java.io.IOException; +import java.util.Optional; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import org.eclipse.jgit.transport.URIish; + +@Singleton +public class ProjectInitializationAction extends HttpServlet { + private static final long serialVersionUID = 1L; + private static final FluentLogger logger = FluentLogger.forEnclosingClass(); + + public static final String PROJECT_NAME = "project-name"; + + private final GerritConfigOps gerritConfigOps; + private final Provider<CurrentUser> userProvider; + private final PermissionBackend permissionBackend; + + @Inject + ProjectInitializationAction( + GerritConfigOps gerritConfigOps, + Provider<CurrentUser> userProvider, + PermissionBackend permissionBackend) { + this.gerritConfigOps = gerritConfigOps; + this.userProvider = userProvider; + this.permissionBackend = permissionBackend; + } + + @Override + protected void doPut( + HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse) + throws ServletException, IOException { + + if (!checkAcceptHeader(httpServletRequest, httpServletResponse)) { + return; + } + + if (!userProvider.get().isIdentifiedUser()) { + setResponse( + httpServletResponse, + HttpServletResponse.SC_UNAUTHORIZED, + "Unauthorized user. '" + CALL_FETCH_ACTION + "' capability needed."); + return; + } + + String path = httpServletRequest.getRequestURI(); + String projectName = Url.decode(path.substring(path.lastIndexOf('/') + 1)); + + try { + if (initProject(projectName)) { + setResponse( + httpServletResponse, + HttpServletResponse.SC_CREATED, + "Project " + projectName + " initialized"); + return; + } + } catch (AuthException | PermissionBackendException e) { + setResponse( + httpServletResponse, + HttpServletResponse.SC_FORBIDDEN, + "User not authorized to create project " + projectName); + return; + } + + setResponse( + httpServletResponse, + HttpServletResponse.SC_INTERNAL_SERVER_ERROR, + "Cannot initialize project " + projectName); + } + + protected boolean initProject(String projectName) + throws AuthException, PermissionBackendException { + permissionBackend.user(userProvider.get()).check(GlobalPermission.CREATE_PROJECT); + + Optional<URIish> maybeUri = gerritConfigOps.getGitRepositoryURI(projectName); + if (!maybeUri.isPresent()) { + logger.atSevere().log("Cannot initialize project '%s'", projectName); + return false; + } + LocalFS localFS = new LocalFS(maybeUri.get()); + Project.NameKey projectNameKey = Project.NameKey.parse(projectName); + return localFS.createProject(projectNameKey, RefNames.HEAD); + } + + public static String getProjectInitializationUrl(String pluginName, String projectName) { + return String.format( + "a/plugins/%s/init-project/%s", pluginName, Url.encode(projectName) + ".git"); + } +}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/PullReplicationApiModule.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/PullReplicationApiModule.java index f94f49b..e7b3a7f 100644 --- a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/PullReplicationApiModule.java +++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/PullReplicationApiModule.java
@@ -27,8 +27,12 @@ protected void configure() { bind(FetchAction.class).in(Scopes.SINGLETON); bind(ApplyObjectAction.class).in(Scopes.SINGLETON); + bind(ProjectDeletionAction.class).in(Scopes.SINGLETON); + bind(UpdateHeadAction.class).in(Scopes.SINGLETON); post(PROJECT_KIND, "fetch").to(FetchAction.class); post(PROJECT_KIND, "apply-object").to(ApplyObjectAction.class); + delete(PROJECT_KIND, "delete-project").to(ProjectDeletionAction.class); + put(PROJECT_KIND, "HEAD").to(UpdateHeadAction.class); bind(FetchPreconditions.class).in(Scopes.SINGLETON); bind(CapabilityDefinition.class)
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/PullReplicationFilter.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/PullReplicationFilter.java index 65b8e1b..4af2bf7 100644 --- a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/PullReplicationFilter.java +++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/PullReplicationFilter.java
@@ -15,23 +15,30 @@ package com.googlesource.gerrit.plugins.replication.pull.api; import static com.google.gerrit.httpd.restapi.RestApiServlet.SC_UNPROCESSABLE_ENTITY; +import static com.googlesource.gerrit.plugins.replication.pull.api.HttpServletOps.checkAcceptHeader; +import static com.googlesource.gerrit.plugins.replication.pull.api.HttpServletOps.setResponse; import static javax.servlet.http.HttpServletResponse.SC_BAD_REQUEST; import static javax.servlet.http.HttpServletResponse.SC_CONFLICT; import static javax.servlet.http.HttpServletResponse.SC_CREATED; import static javax.servlet.http.HttpServletResponse.SC_FORBIDDEN; +import static javax.servlet.http.HttpServletResponse.SC_INTERNAL_SERVER_ERROR; import static javax.servlet.http.HttpServletResponse.SC_OK; import static javax.servlet.http.HttpServletResponse.SC_UNAUTHORIZED; import com.google.common.base.Splitter; import com.google.common.flogger.FluentLogger; +import com.google.gerrit.extensions.annotations.PluginName; +import com.google.gerrit.extensions.api.projects.HeadInput; import com.google.gerrit.extensions.restapi.AuthException; import com.google.gerrit.extensions.restapi.BadRequestException; import com.google.gerrit.extensions.restapi.IdString; import com.google.gerrit.extensions.restapi.ResourceConflictException; +import com.google.gerrit.extensions.restapi.ResourceNotFoundException; import com.google.gerrit.extensions.restapi.Response; import com.google.gerrit.extensions.restapi.RestApiException; import com.google.gerrit.extensions.restapi.TopLevelResource; import com.google.gerrit.extensions.restapi.UnprocessableEntityException; +import com.google.gerrit.extensions.restapi.Url; import com.google.gerrit.httpd.AllRequestFilter; import com.google.gerrit.httpd.restapi.RestApiServlet; import com.google.gerrit.json.OutputFormat; @@ -48,6 +55,7 @@ import com.google.inject.TypeLiteral; import com.googlesource.gerrit.plugins.replication.pull.api.FetchAction.Input; import com.googlesource.gerrit.plugins.replication.pull.api.data.RevisionInput; +import com.googlesource.gerrit.plugins.replication.pull.api.exception.InitProjectException; import java.io.BufferedReader; import java.io.EOFException; import java.io.IOException; @@ -67,20 +75,32 @@ private FetchAction fetchAction; private ApplyObjectAction applyObjectAction; + private ProjectInitializationAction projectInitializationAction; + private UpdateHeadAction updateHEADAction; + private ProjectDeletionAction projectDeletionAction; private ProjectsCollection projectsCollection; private Gson gson; private Provider<CurrentUser> userProvider; + private String pluginName; @Inject public PullReplicationFilter( FetchAction fetchAction, ApplyObjectAction applyObjectAction, + ProjectInitializationAction projectInitializationAction, + UpdateHeadAction updateHEADAction, + ProjectDeletionAction projectDeletionAction, ProjectsCollection projectsCollection, - Provider<CurrentUser> userProvider) { + Provider<CurrentUser> userProvider, + @PluginName String pluginName) { this.fetchAction = fetchAction; this.applyObjectAction = applyObjectAction; + this.projectInitializationAction = projectInitializationAction; + this.updateHEADAction = updateHEADAction; + this.projectDeletionAction = projectDeletionAction; this.projectsCollection = projectsCollection; this.userProvider = userProvider; + this.pluginName = pluginName; this.gson = OutputFormat.JSON.newGsonBuilder().create(); } @@ -107,6 +127,27 @@ } else { httpResponse.sendError(SC_UNAUTHORIZED); } + } else if (isInitProjectAction(httpRequest)) { + if (userProvider.get().isIdentifiedUser()) { + if (!checkAcceptHeader(httpRequest, httpResponse)) { + return; + } + doInitProject(httpRequest, httpResponse); + } else { + httpResponse.sendError(SC_UNAUTHORIZED); + } + } else if (isUpdateHEADAction(httpRequest)) { + if (userProvider.get().isIdentifiedUser()) { + writeResponse(httpResponse, doUpdateHEAD(httpRequest)); + } else { + httpResponse.sendError(SC_UNAUTHORIZED); + } + } else if (isDeleteProjectAction(httpRequest)) { + if (userProvider.get().isIdentifiedUser()) { + writeResponse(httpResponse, doDeleteProject(httpRequest)); + } else { + httpResponse.sendError(SC_UNAUTHORIZED); + } } else { chain.doFilter(request, response); } @@ -126,11 +167,27 @@ } catch (ResourceConflictException e) { RestApiServlet.replyError( httpRequest, httpResponse, SC_CONFLICT, e.getMessage(), e.caching(), e); + } catch (InitProjectException | ResourceNotFoundException e) { + RestApiServlet.replyError( + httpRequest, httpResponse, SC_INTERNAL_SERVER_ERROR, e.getMessage(), e.caching(), e); } catch (Exception e) { throw new ServletException(e); } } + private void doInitProject(HttpServletRequest httpRequest, HttpServletResponse httpResponse) + throws RestApiException, IOException, PermissionBackendException { + + String path = httpRequest.getRequestURI(); + String projectName = Url.decode(path.substring(path.lastIndexOf('/') + 1)); + if (projectInitializationAction.initProject(projectName)) { + setResponse( + httpResponse, HttpServletResponse.SC_CREATED, "Project " + projectName + " initialized"); + return; + } + throw new InitProjectException(projectName); + } + @SuppressWarnings("unchecked") private Response<Map<String, Object>> doApplyObject(HttpServletRequest httpRequest) throws RestApiException, IOException, PermissionBackendException { @@ -142,6 +199,23 @@ } @SuppressWarnings("unchecked") + private Response<String> doUpdateHEAD(HttpServletRequest httpRequest) throws Exception { + HeadInput input = readJson(httpRequest, TypeLiteral.get(HeadInput.class)); + ProjectResource projectResource = + projectsCollection.parse(TopLevelResource.INSTANCE, getProjectName(httpRequest)); + + return (Response<String>) updateHEADAction.apply(projectResource, input); + } + + @SuppressWarnings("unchecked") + private Response<String> doDeleteProject(HttpServletRequest httpRequest) throws Exception { + ProjectResource projectResource = + projectsCollection.parse(TopLevelResource.INSTANCE, getProjectName(httpRequest)); + return (Response<String>) + projectDeletionAction.apply(projectResource, new ProjectDeletionAction.DeleteInput()); + } + + @SuppressWarnings("unchecked") private Response<Map<String, Object>> doFetch(HttpServletRequest httpRequest) throws IOException, RestApiException, PermissionBackendException { Input input = readJson(httpRequest, TypeLiteral.get(Input.class)); @@ -151,8 +225,8 @@ return (Response<Map<String, Object>>) fetchAction.apply(projectResource, input); } - private void writeResponse( - HttpServletResponse httpResponse, Response<Map<String, Object>> response) throws IOException { + private <T> void writeResponse(HttpServletResponse httpResponse, Response<T> response) + throws IOException { String responseJson = gson.toJson(response); if (response.statusCode() == SC_OK || response.statusCode() == SC_CREATED) { @@ -222,4 +296,18 @@ private boolean isFetchAction(HttpServletRequest httpRequest) { return httpRequest.getRequestURI().endsWith("pull-replication~fetch"); } + + private boolean isInitProjectAction(HttpServletRequest httpRequest) { + return httpRequest.getRequestURI().contains("pull-replication/init-project/"); + } + + private boolean isUpdateHEADAction(HttpServletRequest httpRequest) { + return httpRequest.getRequestURI().matches("(/a)?/projects/[^/]+/HEAD") + && "PUT".equals(httpRequest.getMethod()); + } + + private boolean isDeleteProjectAction(HttpServletRequest httpRequest) { + return httpRequest.getRequestURI().endsWith(String.format("%s~delete-project", pluginName)) + && "DELETE".equals(httpRequest.getMethod()); + } }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/UpdateHeadAction.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/UpdateHeadAction.java new file mode 100644 index 0000000..ae9c072 --- /dev/null +++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/UpdateHeadAction.java
@@ -0,0 +1,41 @@ +// Copyright (C) 2021 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.googlesource.gerrit.plugins.replication.pull.api; + +import com.google.gerrit.extensions.api.projects.HeadInput; +import com.google.gerrit.extensions.restapi.AuthException; +import com.google.gerrit.extensions.restapi.BadRequestException; +import com.google.gerrit.extensions.restapi.ResourceConflictException; +import com.google.gerrit.extensions.restapi.Response; +import com.google.gerrit.extensions.restapi.RestModifyView; +import com.google.gerrit.server.project.ProjectResource; +import com.google.gerrit.server.restapi.project.SetHead; +import com.google.inject.Inject; + +public class UpdateHeadAction implements RestModifyView<ProjectResource, HeadInput> { + + private final SetHead setHead; + + @Inject + public UpdateHeadAction(SetHead setHead) { + this.setHead = setHead; + } + + @Override + public Response<?> apply(ProjectResource resource, HeadInput input) + throws AuthException, BadRequestException, ResourceConflictException, Exception { + return setHead.apply(resource, input); + } +}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/data/RevisionData.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/data/RevisionData.java index bcd4e05..9c33d07 100644 --- a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/data/RevisionData.java +++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/data/RevisionData.java
@@ -43,4 +43,15 @@ public List<RevisionObjectData> getBlobs() { return blobs; } + + @Override + public String toString() { + return "{" + + (commitObject != null ? "commitObject=" + commitObject : "") + + " " + + (treeObject != null ? "treeObject=" + treeObject : "") + + " " + + (blobs != null && !blobs.isEmpty() ? "blobs=" + blobs : "") + + "}"; + } }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/data/RevisionInput.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/data/RevisionInput.java index bc3e218..18fc27e 100644 --- a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/data/RevisionInput.java +++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/data/RevisionInput.java
@@ -14,6 +14,10 @@ package com.googlesource.gerrit.plugins.replication.pull.api.data; +import java.util.List; +import java.util.Objects; +import org.eclipse.jgit.lib.Constants; + public class RevisionInput { private String label; @@ -38,4 +42,40 @@ public RevisionData getRevisionData() { return revisionData; } + + public void validate() { + // Non-heads refs can point to non-commit objects + if (!refName.startsWith(Constants.R_HEADS) + && Objects.isNull(revisionData.getCommitObject()) + && Objects.isNull(revisionData.getTreeObject())) { + + List<RevisionObjectData> blobs = revisionData.getBlobs(); + + if (Objects.isNull(blobs) || blobs.isEmpty()) { + throw new IllegalArgumentException( + "Ref " + refName + " cannot have a null or empty list of BLOBs associated"); + } + + if (blobs.size() > 1) { + throw new IllegalArgumentException("Ref " + refName + " has more than one BLOB associated"); + } + + return; + } + + if (Objects.isNull(revisionData.getCommitObject()) + || Objects.isNull(revisionData.getCommitObject().getContent()) + || revisionData.getCommitObject().getContent().length == 0 + || Objects.isNull(revisionData.getCommitObject().getType())) { + throw new IllegalArgumentException( + "Commit object for ref " + refName + " cannot be null or empty"); + } + + if (Objects.isNull(revisionData.getTreeObject()) + || Objects.isNull(revisionData.getTreeObject().getContent()) + || Objects.isNull(revisionData.getTreeObject().getType())) { + throw new IllegalArgumentException( + "Ref-update tree object for ref " + refName + " cannot be null"); + } + } }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/data/RevisionObjectData.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/data/RevisionObjectData.java index 02ba06c..0962717 100644 --- a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/data/RevisionObjectData.java +++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/data/RevisionObjectData.java
@@ -15,6 +15,7 @@ package com.googlesource.gerrit.plugins.replication.pull.api.data; import java.util.Base64; +import org.eclipse.jgit.lib.Constants; public class RevisionObjectData { private final Integer type; @@ -32,4 +33,18 @@ public byte[] getContent() { return Base64.getDecoder().decode(content); } + + @Override + public String toString() { + switch (type) { + case Constants.OBJ_BLOB: + return "BLOB"; + case Constants.OBJ_COMMIT: + return "COMMIT"; + case Constants.OBJ_TREE: + return "TREE"; + default: + return "type:" + type; + } + } }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/exception/InitProjectException.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/exception/InitProjectException.java new file mode 100644 index 0000000..85a7729 --- /dev/null +++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/exception/InitProjectException.java
@@ -0,0 +1,25 @@ +// Copyright (C) 2021 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.googlesource.gerrit.plugins.replication.pull.api.exception; + +import com.google.gerrit.extensions.restapi.RestApiException; + +public class InitProjectException extends RestApiException { + private static final long serialVersionUID = 1L; + + public InitProjectException(String projectName) { + super("Cannot create project " + projectName); + } +}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/client/FetchApiClient.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/client/FetchApiClient.java new file mode 100644 index 0000000..476a35b --- /dev/null +++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/client/FetchApiClient.java
@@ -0,0 +1,46 @@ +// Copyright (C) 2022 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.googlesource.gerrit.plugins.replication.pull.client; + +import com.google.gerrit.entities.Project; +import com.googlesource.gerrit.plugins.replication.pull.Source; +import com.googlesource.gerrit.plugins.replication.pull.api.data.RevisionData; +import java.io.IOException; +import org.apache.http.client.ClientProtocolException; +import org.eclipse.jgit.transport.URIish; + +public interface FetchApiClient { + + public interface Factory { + FetchApiClient create(Source source); + } + + HttpResult callFetch(Project.NameKey project, String refName, URIish targetUri) + throws ClientProtocolException, IOException; + + HttpResult initProject(Project.NameKey project, URIish uri) throws IOException; + + HttpResult deleteProject(Project.NameKey project, URIish apiUri) throws IOException; + + HttpResult updateHead(Project.NameKey project, String newHead, URIish apiUri) throws IOException; + + HttpResult callSendObject( + Project.NameKey project, + String refName, + boolean isDelete, + RevisionData revisionData, + URIish targetUri) + throws ClientProtocolException, IOException; +}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/client/FetchRestApiClient.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/client/FetchRestApiClient.java index 7b876df..f2f57cd 100644 --- a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/client/FetchRestApiClient.java +++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/client/FetchRestApiClient.java
@@ -15,11 +15,13 @@ package com.googlesource.gerrit.plugins.replication.pull.client; import static com.google.gson.FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES; +import static com.googlesource.gerrit.plugins.replication.pull.api.ProjectInitializationAction.getProjectInitializationUrl; import static java.util.Objects.requireNonNull; import com.google.common.base.Strings; import com.google.common.flogger.FluentLogger; import com.google.common.net.MediaType; +import com.google.gerrit.common.Nullable; import com.google.gerrit.entities.Project; import com.google.gerrit.extensions.annotations.PluginName; import com.google.gerrit.extensions.restapi.Url; @@ -43,7 +45,9 @@ import org.apache.http.client.ClientProtocolException; import org.apache.http.client.CredentialsProvider; import org.apache.http.client.ResponseHandler; +import org.apache.http.client.methods.HttpDelete; import org.apache.http.client.methods.HttpPost; +import org.apache.http.client.methods.HttpPut; import org.apache.http.client.protocol.HttpClientContext; import org.apache.http.entity.StringEntity; import org.apache.http.impl.client.BasicCredentialsProvider; @@ -52,17 +56,13 @@ import org.eclipse.jgit.transport.CredentialItem; import org.eclipse.jgit.transport.URIish; -public class FetchRestApiClient implements ResponseHandler<HttpResult> { +public class FetchRestApiClient implements FetchApiClient, ResponseHandler<HttpResult> { private static final FluentLogger logger = FluentLogger.forEnclosingClass(); static String GERRIT_ADMIN_PROTOCOL_PREFIX = "gerrit+"; private static final Gson GSON = new GsonBuilder().setFieldNamingPolicy(LOWER_CASE_WITH_UNDERSCORES).create(); - public interface Factory { - FetchRestApiClient create(Source source); - } - private final CredentialsFactory credentials; private final SourceHttpClient.Factory httpClientFactory; private final Source source; @@ -91,6 +91,10 @@ Strings.emptyToNull(instanceLabel), "replication.instanceLabel cannot be null or empty"); } + /* (non-Javadoc) + * @see com.googlesource.gerrit.plugins.replication.pull.client.FetchApiClient#callFetch(com.google.gerrit.entities.Project.NameKey, java.lang.String, org.eclipse.jgit.transport.URIish) + */ + @Override public HttpResult callFetch(Project.NameKey project, String refName, URIish targetUri) throws ClientProtocolException, IOException { String url = @@ -109,10 +113,65 @@ return httpClientFactory.create(source).execute(post, this, getContext(targetUri)); } + /* (non-Javadoc) + * @see com.googlesource.gerrit.plugins.replication.pull.client.FetchApiClient#initProject(com.google.gerrit.entities.Project.NameKey, org.eclipse.jgit.transport.URIish) + */ + @Override + public HttpResult initProject(Project.NameKey project, URIish uri) throws IOException { + String url = + String.format( + "%s/%s", uri.toString(), getProjectInitializationUrl(pluginName, project.get())); + HttpPut put = new HttpPut(url); + put.addHeader(new BasicHeader("Accept", MediaType.ANY_TEXT_TYPE.toString())); + put.addHeader(new BasicHeader("Content-Type", MediaType.PLAIN_TEXT_UTF_8.toString())); + return httpClientFactory.create(source).execute(put, this, getContext(uri)); + } + + /* (non-Javadoc) + * @see com.googlesource.gerrit.plugins.replication.pull.client.FetchApiClient#deleteProject(com.google.gerrit.entities.Project.NameKey, org.eclipse.jgit.transport.URIish) + */ + @Override + public HttpResult deleteProject(Project.NameKey project, URIish apiUri) throws IOException { + String url = + String.format("%s/%s", apiUri.toASCIIString(), getProjectDeletionUrl(project.get())); + HttpDelete delete = new HttpDelete(url); + return httpClientFactory.create(source).execute(delete, this, getContext(apiUri)); + } + + /* (non-Javadoc) + * @see com.googlesource.gerrit.plugins.replication.pull.client.FetchApiClient#updateHead(com.google.gerrit.entities.Project.NameKey, java.lang.String, org.eclipse.jgit.transport.URIish) + */ + @Override + public HttpResult updateHead(Project.NameKey project, String newHead, URIish apiUri) + throws IOException { + logger.atFine().log("Updating head of %s on %s", project.get(), newHead); + String url = + String.format("%s/%s", apiUri.toASCIIString(), getProjectUpdateHeadUrl(project.get())); + HttpPut req = new HttpPut(url); + req.setEntity( + new StringEntity(String.format("{\"ref\": \"%s\"}", newHead), StandardCharsets.UTF_8)); + req.addHeader(new BasicHeader("Content-Type", MediaType.JSON_UTF_8.toString())); + return httpClientFactory.create(source).execute(req, this, getContext(apiUri)); + } + + /* (non-Javadoc) + * @see com.googlesource.gerrit.plugins.replication.pull.client.FetchApiClient#callSendObject(com.google.gerrit.entities.Project.NameKey, java.lang.String, boolean, com.googlesource.gerrit.plugins.replication.pull.api.data.RevisionData, org.eclipse.jgit.transport.URIish) + */ + @Override public HttpResult callSendObject( - Project.NameKey project, String refName, RevisionData revisionData, URIish targetUri) + Project.NameKey project, + String refName, + boolean isDelete, + @Nullable RevisionData revisionData, + URIish targetUri) throws ClientProtocolException, IOException { + if (!isDelete) { + requireNonNull( + revisionData, "RevisionData MUST not be null when the ref-update is not a DELETE"); + } else { + requireNull(revisionData, "DELETE ref-updates cannot be associated with a RevisionData"); + } RevisionInput input = new RevisionInput(instanceLabel, refName, revisionData); String url = @@ -126,6 +185,12 @@ return httpClientFactory.create(source).execute(post, this, getContext(targetUri)); } + private void requireNull(Object object, String string) { + if (object != null) { + throw new IllegalArgumentException(string); + } + } + @Override public HttpResult handleResponse(HttpResponse response) { Optional<String> responseBody = Optional.empty(); @@ -156,4 +221,12 @@ } return null; } + + String getProjectDeletionUrl(String projectName) { + return String.format("a/projects/%s/%s~delete-project", Url.encode(projectName), pluginName); + } + + String getProjectUpdateHeadUrl(String projectName) { + return String.format("a/projects/%s/%s~HEAD", Url.encode(projectName), pluginName); + } }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/client/HttpResult.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/client/HttpResult.java index dc01295..3efcacf 100644 --- a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/client/HttpResult.java +++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/client/HttpResult.java
@@ -19,6 +19,7 @@ import static javax.servlet.http.HttpServletResponse.SC_NO_CONTENT; import static javax.servlet.http.HttpServletResponse.SC_OK; +import com.google.gerrit.entities.Project; import java.util.Optional; public class HttpResult { @@ -38,7 +39,19 @@ return responseCode == SC_CREATED || responseCode == SC_NO_CONTENT || responseCode == SC_OK; } + public boolean isProjectMissing(Project.NameKey projectName) { + String projectMissingMessage = String.format("Not found: %s", projectName.get()); + return message.map(msg -> msg.contains(projectMissingMessage)).orElse(false); + } + public boolean isParentObjectMissing() { return responseCode == SC_CONFLICT; } + + @Override + public String toString() { + return isSuccessful() + ? "OK" + : "FAILED" + ", status=" + responseCode + message.map(s -> " '" + s + "'").orElse(""); + } }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/fetch/ApplyObject.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/fetch/ApplyObject.java index 03362bf..fca085b 100644 --- a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/fetch/ApplyObject.java +++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/fetch/ApplyObject.java
@@ -46,27 +46,39 @@ try (Repository git = gitManager.openRepository(name)) { ObjectId newObjectID = null; - try (ObjectInserter oi = git.newObjectInserter()) { - RevisionObjectData commitObject = revisionData.getCommitObject(); - RevCommit commit = RevCommit.parse(commitObject.getContent()); - for (RevCommit parent : commit.getParents()) { - if (!git.getObjectDatabase().has(parent.getId())) { - throw new MissingParentObjectException(name, refSpec.getSource(), parent.getId()); - } - } - newObjectID = oi.insert(commitObject.getType(), commitObject.getContent()); + RevisionObjectData commitObject = revisionData.getCommitObject(); - RevisionObjectData treeObject = revisionData.getTreeObject(); - oi.insert(treeObject.getType(), treeObject.getContent()); + try (ObjectInserter oi = git.newObjectInserter()) { + if (commitObject != null) { + RevCommit commit = RevCommit.parse(commitObject.getContent()); + for (RevCommit parent : commit.getParents()) { + if (!git.getObjectDatabase().has(parent.getId())) { + throw new MissingParentObjectException(name, refSpec.getSource(), parent.getId()); + } + } + newObjectID = oi.insert(commitObject.getType(), commitObject.getContent()); + + RevisionObjectData treeObject = revisionData.getTreeObject(); + oi.insert(treeObject.getType(), treeObject.getContent()); + } for (RevisionObjectData rev : revisionData.getBlobs()) { - oi.insert(rev.getType(), rev.getContent()); + ObjectId blobObjectId = oi.insert(rev.getType(), rev.getContent()); + if (newObjectID == null) { + newObjectID = blobObjectId; + } } oi.flush(); } RefUpdate ru = git.updateRef(refSpec.getSource()); ru.setNewObjectId(newObjectID); + + if (commitObject == null) { + // Non-commits must be forced as they do not have a graph associated + ru.setForceUpdate(true); + } + RefUpdate.Result result = ru.update(); return new RefUpdateState(refSpec.getSource(), result);
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/fetch/CGitFetch.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/fetch/CGitFetch.java index 9f055c8..c404e30 100644 --- a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/fetch/CGitFetch.java +++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/fetch/CGitFetch.java
@@ -19,6 +19,7 @@ import com.google.common.collect.Lists; import com.google.inject.Inject; import com.google.inject.assistedinject.Assisted; +import com.googlesource.gerrit.plugins.replication.CredentialsFactory; import com.googlesource.gerrit.plugins.replication.pull.SourceConfiguration; import java.io.BufferedReader; import java.io.File; @@ -30,6 +31,8 @@ import org.eclipse.jgit.errors.TransportException; import org.eclipse.jgit.lib.RefUpdate; import org.eclipse.jgit.lib.Repository; +import org.eclipse.jgit.transport.CredentialItem; +import org.eclipse.jgit.transport.CredentialsProvider; import org.eclipse.jgit.transport.RefSpec; import org.eclipse.jgit.transport.URIish; @@ -40,17 +43,20 @@ private int timeout; @Inject - public CGitFetch(SourceConfiguration config, @Assisted URIish uri, @Assisted Repository git) { - + public CGitFetch( + SourceConfiguration config, + CredentialsFactory cpFactory, + @Assisted URIish uri, + @Assisted Repository git) { this.localProjectDirectory = git.getDirectory(); - this.uri = uri; + this.uri = appendCredentials(uri, cpFactory.create(config.getRemoteConfig().getName())); this.timeout = config.getRemoteConfig().getTimeout(); } @Override public List<RefUpdateState> fetch(List<RefSpec> refsSpec) throws IOException { List<String> refs = refsSpec.stream().map(s -> s.toString()).collect(Collectors.toList()); - List<String> command = Lists.newArrayList("git", "fetch", uri.toASCIIString()); + List<String> command = Lists.newArrayList("git", "fetch", uri.toPrivateASCIIString()); command.addAll(refs); ProcessBuilder pb = new ProcessBuilder().command(command).directory(localProjectDirectory); repLog.info("Fetch references {} from {}", refs, uri); @@ -83,6 +89,19 @@ } } + protected URIish appendCredentials(URIish uri, CredentialsProvider credentialsProvider) { + CredentialItem.Username user = new CredentialItem.Username(); + CredentialItem.Password pass = new CredentialItem.Password(); + if (credentialsProvider.supports(user, pass) + && credentialsProvider.get(uri, user, pass) + && uri.getScheme() != null + && !"ssh".equalsIgnoreCase(uri.getScheme())) { + return uri.setUser(user.getValue()).setPass(String.valueOf(pass.getValue())); + } + + return uri; + } + public boolean waitForTaskToFinish(Process process) throws InterruptedException { if (timeout == 0) { process.waitFor();
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/filter/ExcludedRefsFilter.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/filter/ExcludedRefsFilter.java index f2e4ab3..63755b4 100644 --- a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/filter/ExcludedRefsFilter.java +++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/filter/ExcludedRefsFilter.java
@@ -24,21 +24,8 @@ @Singleton public class ExcludedRefsFilter extends RefsFilter { - @Inject - public ExcludedRefsFilter(ReplicationConfig replicationConfig) { - super(replicationConfig); - } - - @Override - protected List<String> getRefNamePatterns(Config cfg) { - return ImmutableList.<String>builder() - .addAll(getDefaultExcludeRefPatterns()) - .addAll(ImmutableList.copyOf(cfg.getStringList("replication", null, "excludeRefs"))) - .build(); - } - - private List<String> getDefaultExcludeRefPatterns() { - return ImmutableList.of( + public static String[] DEFAULT_REPLICATION_EXCLUDE_REFS = + new String[] { RefNames.REFS_USERS + "*", RefNames.REFS_CONFIG, RefNames.REFS_SEQUENCES + "*", @@ -46,6 +33,20 @@ RefNames.REFS_GROUPS + "*", RefNames.REFS_GROUPNAMES, RefNames.REFS_CACHE_AUTOMERGE + "*", - RefNames.REFS_STARRED_CHANGES + "*"); + RefNames.REFS_STARRED_CHANGES + "*" + }; + + @Inject + public ExcludedRefsFilter(ReplicationConfig replicationConfig) { + super(replicationConfig); + } + + @Override + protected List<String> getRefNamePatterns(Config cfg) { + String[] replicationExcludeRefs = cfg.getStringList("replication", null, "excludeRefs"); + if (replicationExcludeRefs.length == 0) { + replicationExcludeRefs = DEFAULT_REPLICATION_EXCLUDE_REFS; + } + return ImmutableList.copyOf(replicationExcludeRefs); } }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/filter/RefsFilter.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/filter/RefsFilter.java index 0bd7564..1daf638 100644 --- a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/filter/RefsFilter.java +++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/filter/RefsFilter.java
@@ -48,9 +48,6 @@ throw new IllegalArgumentException( String.format("Ref name cannot be null or empty, but was %s", refName)); } - if (refsPatterns.isEmpty()) { - return true; - } for (String pattern : refsPatterns) { if (matchesPattern(refName, pattern)) {
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/filter/SyncRefsFilter.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/filter/SyncRefsFilter.java index a069935..7d004f5 100644 --- a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/filter/SyncRefsFilter.java +++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/filter/SyncRefsFilter.java
@@ -30,6 +30,10 @@ @Override protected List<String> getRefNamePatterns(Config cfg) { - return ImmutableList.copyOf(cfg.getStringList("replication", null, "syncRefs")); + String[] replicationSyncRefs = cfg.getStringList("replication", null, "syncRefs"); + if (replicationSyncRefs.length == 0) { + replicationSyncRefs = new String[] {"*"}; + } + return ImmutableList.copyOf(replicationSyncRefs); } }
diff --git a/src/main/resources/Documentation/config.md b/src/main/resources/Documentation/config.md index d4c5abf..b2470a4 100644 --- a/src/main/resources/Documentation/config.md +++ b/src/main/resources/Documentation/config.md
@@ -169,7 +169,7 @@ treated as single ref matches. So `foo/bar` matches only the ref `foo/bar`, but no other refs. - Following refs are always excluded from the git fetch calls: + By default, the following refs are excluded from the git fetch calls: - refs/users/* - refs/meta/config - refs/sequences/* @@ -179,8 +179,6 @@ - refs/cache-automerge/* - refs/starred-changes/* - By default, all other refs are included. - Note that if you are using @PLUGIN@ together with multi-site, you should explicitly exclude `refs/multi-site/version` from being replicated. @@ -349,6 +347,17 @@ By default, 1 thread. +remote.NAME.createMissingRepositories +: Replicate newly created repositories. + + By default, true. + +remote.NAME.replicateProjectDeletions +: If true, project deletions will also be replicated to the +remote site. + + By default, false, do *not* replicate project deletions. + remote.NAME.authGroup : Specifies the name of a group that the remote should use to access the repositories. Multiple authGroups may be specified
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/CGitFetchIT.java b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/CGitFetchIT.java index a02f43f..406cd8c 100644 --- a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/CGitFetchIT.java +++ b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/CGitFetchIT.java
@@ -34,7 +34,12 @@ import com.google.gerrit.extensions.config.FactoryModule; import com.google.gerrit.server.config.SitePaths; import com.google.inject.Inject; +import com.google.inject.Scopes; import com.google.inject.assistedinject.FactoryModuleBuilder; +import com.googlesource.gerrit.plugins.replication.AutoReloadSecureCredentialsFactoryDecorator; +import com.googlesource.gerrit.plugins.replication.CredentialsFactory; +import com.googlesource.gerrit.plugins.replication.ReplicationConfig; +import com.googlesource.gerrit.plugins.replication.ReplicationFileBasedConfig; import com.googlesource.gerrit.plugins.replication.pull.fetch.BatchFetchClient; import com.googlesource.gerrit.plugins.replication.pull.fetch.CGitFetch; import com.googlesource.gerrit.plugins.replication.pull.fetch.Fetch; @@ -270,6 +275,10 @@ try { RemoteConfig remoteConfig = new RemoteConfig(cf, "test_config"); SourceConfiguration sourceConfig = new SourceConfiguration(remoteConfig, cf); + bind(ReplicationConfig.class).to(ReplicationFileBasedConfig.class); + bind(CredentialsFactory.class) + .to(AutoReloadSecureCredentialsFactoryDecorator.class) + .in(Scopes.SINGLETON); bind(SourceConfiguration.class).toInstance(sourceConfig); install(
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/FakeHeadUpdateEvent.java b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/FakeHeadUpdateEvent.java new file mode 100644 index 0000000..d1769ab --- /dev/null +++ b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/FakeHeadUpdateEvent.java
@@ -0,0 +1,52 @@ +// Copyright (C) 2021 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.googlesource.gerrit.plugins.replication.pull; + +import com.google.gerrit.extensions.api.changes.NotifyHandling; +import com.google.gerrit.extensions.events.HeadUpdatedListener; + +class FakeHeadUpdateEvent implements HeadUpdatedListener.Event { + + private final String oldName; + private final String newName; + private final String projectName; + + FakeHeadUpdateEvent(String oldName, String newName, String projectName) { + + this.oldName = oldName; + this.newName = newName; + this.projectName = projectName; + } + + @Override + public NotifyHandling getNotify() { + return NotifyHandling.NONE; + } + + @Override + public String getOldHeadName() { + return oldName; + } + + @Override + public String getNewHeadName() { + return newName; + } + + @Override + public String getProjectName() { + return projectName; + } +}
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/PullReplicationIT.java b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/PullReplicationIT.java index 9557025..c6d32bf 100644 --- a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/PullReplicationIT.java +++ b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/PullReplicationIT.java
@@ -15,6 +15,10 @@ package com.googlesource.gerrit.plugins.replication.pull; import static com.google.common.truth.Truth.assertThat; +import static com.google.gerrit.acceptance.GitUtil.fetch; +import static com.google.gerrit.acceptance.GitUtil.pushOne; +import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow; +import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS; import static java.util.stream.Collectors.toList; import com.google.common.flogger.FluentLogger; @@ -24,26 +28,49 @@ import com.google.gerrit.acceptance.TestPlugin; import com.google.gerrit.acceptance.UseLocalDisk; import com.google.gerrit.acceptance.testsuite.project.ProjectOperations; +import com.google.gerrit.common.data.Permission; import com.google.gerrit.entities.Project; +import com.google.gerrit.entities.Project.NameKey; +import com.google.gerrit.entities.RefNames; +import com.google.gerrit.extensions.api.changes.NotifyHandling; import com.google.gerrit.extensions.api.projects.BranchInput; +import com.google.gerrit.extensions.common.Input; import com.google.gerrit.extensions.events.GitReferenceUpdatedListener; +import com.google.gerrit.extensions.events.HeadUpdatedListener; +import com.google.gerrit.extensions.events.ProjectDeletedListener; +import com.google.gerrit.extensions.registration.DynamicSet; +import com.google.gerrit.extensions.restapi.AuthException; +import com.google.gerrit.extensions.restapi.BadRequestException; +import com.google.gerrit.extensions.restapi.ResourceConflictException; +import com.google.gerrit.extensions.restapi.Response; +import com.google.gerrit.extensions.restapi.RestApiException; +import com.google.gerrit.extensions.restapi.RestModifyView; import com.google.gerrit.server.config.SitePaths; +import com.google.gerrit.server.project.ProjectResource; import com.google.inject.Inject; +import com.google.inject.Singleton; import com.googlesource.gerrit.plugins.replication.AutoReloadConfigDecorator; import java.io.IOException; import java.nio.file.Path; import java.time.Duration; import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; import java.util.List; import java.util.Optional; import java.util.function.Supplier; import org.eclipse.jgit.errors.ConfigInvalidException; +import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository; +import org.eclipse.jgit.junit.TestRepository; import org.eclipse.jgit.lib.ObjectId; import org.eclipse.jgit.lib.Ref; import org.eclipse.jgit.lib.Repository; import org.eclipse.jgit.revwalk.RevCommit; import org.eclipse.jgit.storage.file.FileBasedConfig; +import org.eclipse.jgit.transport.PushResult; import org.eclipse.jgit.transport.ReceiveCommand; +import org.eclipse.jgit.transport.RemoteRefUpdate; +import org.eclipse.jgit.transport.RemoteRefUpdate.Status; import org.eclipse.jgit.util.FS; import org.junit.Test; @@ -56,12 +83,13 @@ private static final Optional<String> ALL_PROJECTS = Optional.empty(); private static final FluentLogger logger = FluentLogger.forEnclosingClass(); private static final int TEST_REPLICATION_DELAY = 60; - private static final Duration TEST_TIMEOUT = Duration.ofSeconds(TEST_REPLICATION_DELAY * 2); + private static final Duration TEST_TIMEOUT = Duration.ofSeconds(TEST_REPLICATION_DELAY * 2000); private static final String TEST_REPLICATION_SUFFIX = "suffix1"; private static final String TEST_REPLICATION_REMOTE = "remote1"; @Inject private SitePaths sitePaths; @Inject private ProjectOperations projectOperations; + @Inject private DynamicSet<ProjectDeletedListener> deletedListeners; private Path gitPath; private FileBasedConfig config; private FileBasedConfig secureConfig; @@ -147,6 +175,76 @@ } @Test + @UseLocalDisk + public void shouldReplicateForceUpdatedBranch() throws Exception { + boolean forcedPush = true; + String testProjectName = project + TEST_REPLICATION_SUFFIX; + NameKey testProjectNameKey = createTestProject(testProjectName); + + String newBranch = "refs/heads/mybranch"; + String master = "refs/heads/master"; + BranchInput input = new BranchInput(); + input.revision = master; + gApi.projects().name(testProjectName).branch(newBranch).create(input); + + projectOperations + .project(testProjectNameKey) + .forUpdate() + .add(allow(Permission.PUSH).ref(newBranch).group(REGISTERED_USERS).force(true)) + .update(); + + String branchRevision = gApi.projects().name(testProjectName).branch(newBranch).get().revision; + + ReplicationQueue pullReplicationQueue = + plugin.getSysInjector().getInstance(ReplicationQueue.class); + GitReferenceUpdatedListener.Event event = + new FakeGitReferenceUpdatedEvent( + project, + newBranch, + ObjectId.zeroId().getName(), + branchRevision, + ReceiveCommand.Type.CREATE); + pullReplicationQueue.onGitReferenceUpdated(event); + + try (Repository repo = repoManager.openRepository(project)) { + waitUntil(() -> checkedGetRef(repo, newBranch) != null); + + Ref targetBranchRef = getRef(repo, newBranch); + assertThat(targetBranchRef).isNotNull(); + assertThat(targetBranchRef.getObjectId().getName()).isEqualTo(branchRevision); + } + + TestRepository<InMemoryRepository> testProject = cloneProject(testProjectNameKey); + fetch(testProject, RefNames.REFS_HEADS + "*:" + RefNames.REFS_HEADS + "*"); + RevCommit amendedCommit = testProject.amendRef(newBranch).message("Amended commit").create(); + PushResult pushResult = + pushOne(testProject, newBranch, newBranch, false, forcedPush, Collections.emptyList()); + Collection<RemoteRefUpdate> pushedRefs = pushResult.getRemoteUpdates(); + assertThat(pushedRefs).hasSize(1); + assertThat(pushedRefs.iterator().next().getStatus()).isEqualTo(Status.OK); + + GitReferenceUpdatedListener.Event forcedPushEvent = + new FakeGitReferenceUpdatedEvent( + project, + newBranch, + branchRevision, + amendedCommit.getId().getName(), + ReceiveCommand.Type.UPDATE_NONFASTFORWARD); + pullReplicationQueue.onGitReferenceUpdated(forcedPushEvent); + + try (Repository repo = repoManager.openRepository(project); + Repository sourceRepo = repoManager.openRepository(project)) { + waitUntil( + () -> + checkedGetRef(repo, newBranch) != null + && checkedGetRef(repo, newBranch) + .getObjectId() + .getName() + .equals(amendedCommit.getId().getName())); + } + } + + @Test public void shouldReplicateNewChangeRefCGitClient() throws Exception { AutoReloadConfigDecorator autoReloadConfigDecorator = getInstance(AutoReloadConfigDecorator.class); @@ -222,6 +320,66 @@ } } + @Test + public void shouldReplicateProjectDeletion() throws Exception { + String projectToDelete = project.get(); + setReplicationSource(TEST_REPLICATION_REMOTE, "", Optional.of(projectToDelete)); + config.save(); + AutoReloadConfigDecorator autoReloadConfigDecorator = + getInstance(AutoReloadConfigDecorator.class); + autoReloadConfigDecorator.reload(); + + ProjectDeletedListener.Event event = + new ProjectDeletedListener.Event() { + @Override + public String getProjectName() { + return projectToDelete; + } + + @Override + public NotifyHandling getNotify() { + return NotifyHandling.NONE; + } + }; + for (ProjectDeletedListener l : deletedListeners) { + l.onProjectDeleted(event); + } + + waitUntil(() -> !repoManager.list().contains(project)); + } + + @Test + public void shouldReplicateHeadUpdate() throws Exception { + String testProjectName = project.get(); + setReplicationSource(TEST_REPLICATION_REMOTE, "", Optional.of(testProjectName)); + config.save(); + AutoReloadConfigDecorator autoReloadConfigDecorator = + getInstance(AutoReloadConfigDecorator.class); + autoReloadConfigDecorator.reload(); + + String newBranch = "refs/heads/mybranch"; + String master = "refs/heads/master"; + BranchInput input = new BranchInput(); + input.revision = master; + gApi.projects().name(testProjectName).branch(newBranch).create(input); + String branchRevision = gApi.projects().name(testProjectName).branch(newBranch).get().revision; + + ReplicationQueue pullReplicationQueue = + plugin.getSysInjector().getInstance(ReplicationQueue.class); + + HeadUpdatedListener.Event event = new FakeHeadUpdateEvent(master, newBranch, testProjectName); + pullReplicationQueue.onHeadUpdated(event); + + waitUntil( + () -> { + try { + return gApi.projects().name(testProjectName).head().equals(newBranch); + } catch (RestApiException e) { + return false; + } + }); + } + private Ref getRef(Repository repo, String branchName) throws IOException { return repo.getRefDatabase().exactRef(branchName); } @@ -278,4 +436,24 @@ private Project.NameKey createTestProject(String name) throws Exception { return projectOperations.newProject().name(name).create(); } + + @Singleton + public static class FakeDeleteProjectPlugin implements RestModifyView<ProjectResource, Input> { + private int deleteEndpointCalls; + + FakeDeleteProjectPlugin() { + this.deleteEndpointCalls = 0; + } + + @Override + public Response<?> apply(ProjectResource resource, Input input) + throws AuthException, BadRequestException, ResourceConflictException, Exception { + deleteEndpointCalls += 1; + return Response.ok(); + } + + int getDeleteEndpointCalls() { + return deleteEndpointCalls; + } + } }
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/ReplicationQueueTest.java b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/ReplicationQueueTest.java index 327c6ba..08d7e5a 100644 --- a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/ReplicationQueueTest.java +++ b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/ReplicationQueueTest.java
@@ -14,20 +14,28 @@ package com.googlesource.gerrit.plugins.replication.pull; +import static com.google.common.truth.Truth.assertThat; import static java.nio.file.Files.createTempDirectory; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.any; import static org.mockito.Mockito.anyString; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyZeroInteractions; import static org.mockito.Mockito.when; import com.google.common.collect.ImmutableList; import com.google.common.collect.Lists; +import com.google.gerrit.entities.Project; import com.google.gerrit.extensions.api.changes.NotifyHandling; import com.google.gerrit.extensions.common.AccountInfo; import com.google.gerrit.extensions.events.GitReferenceUpdatedListener; import com.google.gerrit.extensions.events.GitReferenceUpdatedListener.Event; +import com.google.gerrit.extensions.events.ProjectDeletedListener; import com.google.gerrit.extensions.registration.DynamicItem; +import com.google.gerrit.metrics.DisabledMetricMaker; import com.google.gerrit.server.config.SitePaths; import com.google.gerrit.server.events.EventDispatcher; import com.google.gerrit.server.git.WorkQueue; @@ -36,6 +44,7 @@ import com.googlesource.gerrit.plugins.replication.ReplicationFileBasedConfig; import com.googlesource.gerrit.plugins.replication.pull.api.data.RevisionData; import com.googlesource.gerrit.plugins.replication.pull.api.exception.RefUpdateException; +import com.googlesource.gerrit.plugins.replication.pull.client.FetchApiClient; import com.googlesource.gerrit.plugins.replication.pull.client.FetchRestApiClient; import com.googlesource.gerrit.plugins.replication.pull.client.HttpResult; import com.googlesource.gerrit.plugins.replication.pull.filter.ExcludedRefsFilter; @@ -50,6 +59,8 @@ import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; import org.mockito.Mock; import org.mockito.junit.MockitoJUnitRunner; @@ -64,11 +75,15 @@ @Mock private DynamicItem<EventDispatcher> dis; @Mock ReplicationStateListeners sl; @Mock FetchRestApiClient fetchRestApiClient; - @Mock FetchRestApiClient.Factory fetchClientFactory; + @Mock FetchApiClient.Factory fetchClientFactory; @Mock AccountInfo accountInfo; @Mock RevisionReader revReader; @Mock RevisionData revisionData; @Mock HttpResult httpResult; + ApplyObjectMetrics applyObjectMetrics; + + @Captor ArgumentCaptor<String> stringCaptor; + @Captor ArgumentCaptor<Project.NameKey> projectNameKeyCaptor; private ExcludedRefsFilter refsFilter; private ReplicationQueue objectUnderTest; @@ -76,7 +91,7 @@ private Path pluginDataPath; @Before - public void setup() throws IOException, LargeObjectException, RefUpdateException { + public void setup() throws IOException, LargeObjectException { Path sitePath = createTempPath("site"); sitePaths = new SitePaths(sitePath); Path pluginDataPath = createTempPath("data"); @@ -89,15 +104,19 @@ when(source.getApis()).thenReturn(apis); when(sourceCollection.getAll()).thenReturn(Lists.newArrayList(source)); when(rd.get()).thenReturn(sourceCollection); - when(revReader.read(any(), anyString())).thenReturn(Optional.of(revisionData)); + when(revReader.read(any(), any(), anyString())).thenReturn(Optional.of(revisionData)); when(fetchClientFactory.create(any())).thenReturn(fetchRestApiClient); - when(fetchRestApiClient.callSendObject(any(), anyString(), any(), any())) + when(fetchRestApiClient.callSendObject(any(), anyString(), anyBoolean(), any(), any())) .thenReturn(httpResult); when(fetchRestApiClient.callFetch(any(), anyString(), any())).thenReturn(httpResult); when(httpResult.isSuccessful()).thenReturn(true); + when(httpResult.isProjectMissing(any())).thenReturn(false); + + applyObjectMetrics = new ApplyObjectMetrics("pull-replication", new DisabledMetricMaker()); objectUnderTest = - new ReplicationQueue(wq, rd, dis, sl, fetchClientFactory, refsFilter, revReader); + new ReplicationQueue( + wq, rd, dis, sl, fetchClientFactory, refsFilter, revReader, applyObjectMetrics); } @Test @@ -106,7 +125,33 @@ objectUnderTest.start(); objectUnderTest.onGitReferenceUpdated(event); - verify(fetchRestApiClient).callSendObject(any(), anyString(), any(), any()); + verify(fetchRestApiClient).callSendObject(any(), anyString(), eq(false), any(), any()); + } + + @Test + public void shouldCallInitProjectWhenProjectIsMissing() throws IOException { + Event event = new TestEvent("refs/changes/01/1/meta"); + when(httpResult.isSuccessful()).thenReturn(false); + when(httpResult.isProjectMissing(any())).thenReturn(true); + when(source.isCreateMissingRepositories()).thenReturn(true); + + objectUnderTest.start(); + objectUnderTest.onGitReferenceUpdated(event); + + verify(fetchRestApiClient).initProject(any(), any()); + } + + @Test + public void shouldNotCallInitProjectWhenReplicateNewRepositoriesNotSet() throws IOException { + Event event = new TestEvent("refs/changes/01/1/meta"); + when(httpResult.isSuccessful()).thenReturn(false); + when(httpResult.isProjectMissing(any())).thenReturn(true); + when(source.isCreateMissingRepositories()).thenReturn(false); + + objectUnderTest.start(); + objectUnderTest.onGitReferenceUpdated(event); + + verify(fetchRestApiClient, never()).initProject(any(), any()); } @Test @@ -115,7 +160,7 @@ objectUnderTest.start(); objectUnderTest.onGitReferenceUpdated(event); - verify(fetchRestApiClient).callSendObject(any(), anyString(), any(), any()); + verify(fetchRestApiClient).callSendObject(any(), anyString(), eq(false), any(), any()); } @Test @@ -124,7 +169,7 @@ Event event = new TestEvent("refs/changes/01/1/meta"); objectUnderTest.start(); - when(revReader.read(any(), anyString())).thenThrow(IOException.class); + when(revReader.read(any(), any(), anyString())).thenThrow(IOException.class); objectUnderTest.onGitReferenceUpdated(event); @@ -137,7 +182,7 @@ Event event = new TestEvent("refs/changes/01/1/1"); objectUnderTest.start(); - when(revReader.read(any(), anyString())).thenReturn(Optional.empty()); + when(revReader.read(any(), any(), anyString())).thenReturn(Optional.empty()); objectUnderTest.onGitReferenceUpdated(event); @@ -152,7 +197,7 @@ when(httpResult.isSuccessful()).thenReturn(false); when(httpResult.isParentObjectMissing()).thenReturn(true); - when(fetchRestApiClient.callSendObject(any(), anyString(), any(), any())) + when(fetchRestApiClient.callSendObject(any(), anyString(), eq(false), any(), any())) .thenReturn(httpResult); objectUnderTest.onGitReferenceUpdated(event); @@ -202,7 +247,8 @@ refsFilter = new ExcludedRefsFilter(replicationConfig); objectUnderTest = - new ReplicationQueue(wq, rd, dis, sl, fetchClientFactory, refsFilter, revReader); + new ReplicationQueue( + wq, rd, dis, sl, fetchClientFactory, refsFilter, revReader, applyObjectMetrics); Event event = new TestEvent("refs/multi-site/version"); objectUnderTest.onGitReferenceUpdated(event); @@ -233,6 +279,62 @@ verifyZeroInteractions(wq, rd, dis, sl, fetchClientFactory, accountInfo); } + @Test + public void shouldCallDeleteWhenReplicateProjectDeletionsTrue() throws IOException { + when(source.wouldDeleteProject(any())).thenReturn(true); + + String projectName = "testProject"; + FakeProjectDeletedEvent event = new FakeProjectDeletedEvent(projectName); + + objectUnderTest.start(); + objectUnderTest.onProjectDeleted(event); + + verify(source, times(1)) + .scheduleDeleteProject(stringCaptor.capture(), projectNameKeyCaptor.capture()); + assertThat(stringCaptor.getValue()).isEqualTo(source.getApis().get(0)); + assertThat(projectNameKeyCaptor.getValue()).isEqualTo(Project.NameKey.parse(projectName)); + } + + @Test + public void shouldNotCallDeleteWhenProjectNotToDelete() throws IOException { + when(source.wouldDeleteProject(any())).thenReturn(false); + + FakeProjectDeletedEvent event = new FakeProjectDeletedEvent("testProject"); + + objectUnderTest.start(); + objectUnderTest.onProjectDeleted(event); + + verify(source, never()).scheduleDeleteProject(any(), any()); + } + + @Test + public void shouldScheduleUpdateHeadWhenWouldFetchProject() throws IOException { + when(source.wouldFetchProject(any())).thenReturn(true); + + String projectName = "aProject"; + String newHEAD = "newHEAD"; + + objectUnderTest.start(); + objectUnderTest.onHeadUpdated(new FakeHeadUpdateEvent("oldHead", newHEAD, projectName)); + verify(source, times(1)) + .scheduleUpdateHead(any(), projectNameKeyCaptor.capture(), stringCaptor.capture()); + + assertThat(stringCaptor.getValue()).isEqualTo(newHEAD); + assertThat(projectNameKeyCaptor.getValue()).isEqualTo(Project.NameKey.parse(projectName)); + } + + @Test + public void shouldNotScheduleUpdateHeadWhenNotWouldFetchProject() throws IOException { + when(source.wouldFetchProject(any())).thenReturn(false); + + String projectName = "aProject"; + String newHEAD = "newHEAD"; + + objectUnderTest.start(); + objectUnderTest.onHeadUpdated(new FakeHeadUpdateEvent("oldHead", newHEAD, projectName)); + verify(source, never()).scheduleUpdateHead(any(), any(), any()); + } + protected static Path createTempPath(String prefix) throws IOException { return createTempDirectory(prefix); } @@ -269,7 +371,7 @@ @Override public String getOldObjectId() { - return null; + return ObjectId.zeroId().getName(); } @Override @@ -297,4 +399,22 @@ return null; } } + + private class FakeProjectDeletedEvent implements ProjectDeletedListener.Event { + private String projectName; + + public FakeProjectDeletedEvent(String projectName) { + this.projectName = projectName; + } + + @Override + public NotifyHandling getNotify() { + return null; + } + + @Override + public String getProjectName() { + return projectName; + } + } }
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/RevisionReaderIT.java b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/RevisionReaderIT.java index d1a4c85..e300613 100644 --- a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/RevisionReaderIT.java +++ b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/RevisionReaderIT.java
@@ -18,6 +18,7 @@ import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; +import com.google.common.truth.Truth8; import com.google.gerrit.acceptance.LightweightPluginDaemonTest; import com.google.gerrit.acceptance.PushOneCommit.Result; import com.google.gerrit.acceptance.TestPlugin; @@ -29,14 +30,19 @@ import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput; import com.google.gerrit.extensions.client.Comment; import com.google.gerrit.extensions.config.FactoryModule; +import com.google.gerrit.server.notedb.Sequences; import com.google.inject.Scopes; import com.googlesource.gerrit.plugins.replication.ReplicationConfig; import com.googlesource.gerrit.plugins.replication.ReplicationFileBasedConfig; import com.googlesource.gerrit.plugins.replication.pull.api.data.RevisionData; import com.googlesource.gerrit.plugins.replication.pull.api.data.RevisionObjectData; import com.googlesource.gerrit.plugins.replication.pull.fetch.ApplyObject; +import java.io.IOException; import java.util.Optional; import org.eclipse.jgit.lib.Constants; +import org.eclipse.jgit.lib.ObjectId; +import org.eclipse.jgit.lib.Ref; +import org.eclipse.jgit.lib.Repository; import org.junit.Before; import org.junit.Test; @@ -57,7 +63,8 @@ Result pushResult = createChange(); String refName = RefNames.changeMetaRef(pushResult.getChange().getId()); - Optional<RevisionData> revisionDataOption = objectUnderTest.read(project, refName); + Optional<RevisionData> revisionDataOption = + refObjectId(refName).flatMap(objId -> readRevisionFromObjectUnderTest(refName, objId)); assertThat(revisionDataOption.isPresent()).isTrue(); RevisionData revisionData = revisionDataOption.get(); @@ -73,6 +80,20 @@ assertThat(revisionData.getBlobs()).isEmpty(); } + private Optional<RevisionData> readRevisionFromObjectUnderTest(String refName, ObjectId objId) { + try { + return objectUnderTest.read(project, objId, refName); + } catch (Exception e) { + throw new IllegalStateException(e); + } + } + + protected Optional<ObjectId> refObjectId(String refName) throws IOException { + try (Repository repo = repoManager.openRepository(project)) { + return Optional.ofNullable(repo.exactRef(refName)).map(Ref::getObjectId); + } + } + @Test public void shouldReadRefMetaObjectWithComments() throws Exception { Result pushResult = createChange(); @@ -85,7 +106,8 @@ reviewInput.comments = ImmutableMap.of(Patch.COMMIT_MSG, ImmutableList.of(comment)); gApi.changes().id(changeId.get()).current().review(reviewInput); - Optional<RevisionData> revisionDataOption = objectUnderTest.read(project, refName); + Optional<RevisionData> revisionDataOption = + refObjectId(refName).flatMap(objId -> readRevisionFromObjectUnderTest(refName, objId)); assertThat(revisionDataOption.isPresent()).isTrue(); RevisionData revisionData = revisionDataOption.get(); @@ -104,6 +126,16 @@ assertThat(blobObject.getContent()).isNotEmpty(); } + @Test + public void shouldNotReadRefsSequences() throws Exception { + createChange().assertOkStatus(); + String refName = RefNames.REFS_SEQUENCES + Sequences.NAME_CHANGES; + Optional<RevisionData> revisionDataOption = + refObjectId(refName).flatMap(objId -> readRevisionFromObjectUnderTest(refName, objId)); + + Truth8.assertThat(revisionDataOption).isEmpty(); + } + private CommentInput createCommentInput( int startLine, int startCharacter, int endLine, int endCharacter, String message) { CommentInput comment = new CommentInput();
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/ActionITBase.java b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/ActionITBase.java index 49cf608..cef1051 100644 --- a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/ActionITBase.java +++ b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/ActionITBase.java
@@ -20,6 +20,7 @@ import com.google.gerrit.acceptance.LightweightPluginDaemonTest; import com.google.gerrit.acceptance.PushOneCommit.Result; import com.google.gerrit.acceptance.SkipProjectClone; +import com.google.gerrit.acceptance.TestAccount; import com.google.gerrit.acceptance.TestPlugin; import com.google.gerrit.acceptance.UseLocalDisk; import com.google.gerrit.acceptance.testsuite.project.ProjectOperations; @@ -47,11 +48,14 @@ import org.apache.http.client.ClientProtocolException; import org.apache.http.client.CredentialsProvider; import org.apache.http.client.ResponseHandler; +import org.apache.http.client.methods.HttpDelete; import org.apache.http.client.methods.HttpPost; +import org.apache.http.client.methods.HttpPut; import org.apache.http.client.protocol.HttpClientContext; import org.apache.http.entity.StringEntity; import org.apache.http.impl.client.BasicCredentialsProvider; import org.apache.http.message.BasicHeader; +import org.eclipse.jgit.lib.Repository; import org.eclipse.jgit.storage.file.FileBasedConfig; import org.eclipse.jgit.util.FS; @@ -79,7 +83,7 @@ SourceHttpClient.Factory httpClientFactory; String url; - protected abstract String getURL(); + protected abstract String getURL(String projectName); @Override public void setUpTestPlugin() throws Exception { @@ -105,7 +109,7 @@ revisionReader = plugin.getSysInjector().getInstance(RevisionReader.class); source = plugin.getSysInjector().getInstance(SourcesCollection.class).getAll().get(0); - url = getURL(); + url = getURL(project.get()); } protected HttpPost createRequest(String sendObjectPayload) { @@ -115,6 +119,18 @@ return post; } + protected HttpPut createPutRequest(String sendObjectPayload) { + HttpPut put = new HttpPut(url); + put.setEntity(new StringEntity(sendObjectPayload, StandardCharsets.UTF_8)); + put.addHeader(new BasicHeader("Content-Type", "application/json")); + return put; + } + + protected HttpDelete createDeleteRequest() { + HttpDelete delete = new HttpDelete(url); + return delete; + } + protected String createRef() throws Exception { return createRef(Project.nameKey(project + TEST_REPLICATION_SUFFIX)); } @@ -132,7 +148,9 @@ protected Optional<RevisionData> createRevisionData(NameKey projectName, String refName) throws Exception { - return revisionReader.read(projectName, refName); + try (Repository repository = repoManager.openRepository(projectName)) { + return revisionReader.read(projectName, repository.exactRef(refName).getObjectId(), refName); + } } protected Object encode(byte[] content) { @@ -152,12 +170,11 @@ } protected HttpClientContext getContext() { - HttpClientContext ctx = HttpClientContext.create(); - CredentialsProvider adapted = new BasicCredentialsProvider(); - adapted.setCredentials( - AuthScope.ANY, new UsernamePasswordCredentials(admin.username(), admin.httpPassword())); - ctx.setCredentialsProvider(adapted); - return ctx; + return getContextForAccount(admin); + } + + protected HttpClientContext getUserContext() { + return getContextForAccount(user); } protected HttpClientContext getAnonymousContext() { @@ -199,4 +216,13 @@ secureConfig.setString("remote", remoteName, "password", password); secureConfig.save(); } + + private HttpClientContext getContextForAccount(TestAccount account) { + HttpClientContext ctx = HttpClientContext.create(); + CredentialsProvider adapted = new BasicCredentialsProvider(); + adapted.setCredentials( + AuthScope.ANY, new UsernamePasswordCredentials(account.username(), account.httpPassword())); + ctx.setCredentialsProvider(adapted); + return ctx; + } }
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/ApplyObjectActionIT.java b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/ApplyObjectActionIT.java index ec172ad..aad3903 100644 --- a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/ApplyObjectActionIT.java +++ b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/ApplyObjectActionIT.java
@@ -176,9 +176,9 @@ } @Override - protected String getURL() { + protected String getURL(String projectName) { return String.format( "%s/a/projects/%s/pull-replication~apply-object", - adminRestSession.url(), Url.encode(project.get())); + adminRestSession.url(), Url.encode(projectName)); } }
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/ApplyObjectActionTest.java b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/ApplyObjectActionTest.java index 8738046..3678e82 100644 --- a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/ApplyObjectActionTest.java +++ b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/ApplyObjectActionTest.java
@@ -35,6 +35,8 @@ import com.googlesource.gerrit.plugins.replication.pull.api.exception.MissingParentObjectException; import com.googlesource.gerrit.plugins.replication.pull.api.exception.RefUpdateException; import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; import org.eclipse.jgit.lib.Constants; import org.eclipse.jgit.lib.ObjectId; import org.junit.Before; @@ -49,6 +51,7 @@ String label = "instance-2-label"; String url = "file:///gerrit-host/instance-1/git/${name}.git"; String refName = "refs/heads/master"; + String refMetaName = "refs/meta/version"; String location = "http://gerrit-host/a/config/server/tasks/08d173e9"; int taskId = 1234; @@ -72,6 +75,7 @@ + "Submitted-with: OK: Code-Review: Gerrit User 1000000 <1000000@69ec38f0-350e-4d9c-96d4-bc956f2faaac>"; @Mock ApplyObjectCommand applyObjectCommand; + @Mock DeleteRefCommand deleteRefCommand; @Mock ProjectResource projectResource; @Mock FetchPreconditions preConditions; @@ -79,7 +83,7 @@ public void setup() { when(preConditions.canCallFetchApi()).thenReturn(true); - applyObjectAction = new ApplyObjectAction(applyObjectCommand, preConditions); + applyObjectAction = new ApplyObjectAction(applyObjectCommand, deleteRefCommand, preConditions); } @Test @@ -91,6 +95,20 @@ assertThat(response.statusCode()).isEqualTo(SC_CREATED); } + @Test + public void shouldReturnCreatedResponseCodeForBlob() throws RestApiException { + byte[] blobData = "foo".getBytes(StandardCharsets.UTF_8); + RevisionInput inputParams = + new RevisionInput( + label, + refMetaName, + createSampleRevisionDataBlob(new RevisionObjectData(Constants.OBJ_BLOB, blobData))); + + Response<?> response = applyObjectAction.apply(projectResource, inputParams); + + assertThat(response.statusCode()).isEqualTo(SC_CREATED); + } + @SuppressWarnings("cast") @Test public void shouldReturnSourceUrlAndrefNameAsAResponseBody() throws Exception { @@ -129,13 +147,6 @@ } @Test(expected = BadRequestException.class) - public void shouldThrowBadRequestExceptionWhenMissingRevisionData() throws Exception { - RevisionInput inputParams = new RevisionInput(label, refName, null); - - applyObjectAction.apply(projectResource, inputParams); - } - - @Test(expected = BadRequestException.class) public void shouldThrowBadRequestExceptionWhenMissingCommitObjectData() throws Exception { RevisionObjectData commitData = new RevisionObjectData(Constants.OBJ_COMMIT, null); RevisionObjectData treeData = new RevisionObjectData(Constants.OBJ_TREE, new byte[] {}); @@ -190,4 +201,8 @@ RevisionObjectData commitData, RevisionObjectData treeData) { return new RevisionData(commitData, treeData, Lists.newArrayList()); } + + private RevisionData createSampleRevisionDataBlob(RevisionObjectData blob) { + return new RevisionData(null, null, Arrays.asList(blob)); + } }
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/DeleteRefCommandTest.java b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/DeleteRefCommandTest.java new file mode 100644 index 0000000..daf2001 --- /dev/null +++ b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/DeleteRefCommandTest.java
@@ -0,0 +1,77 @@ +// Copyright (C) 2022 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.googlesource.gerrit.plugins.replication.pull.api; + +import static com.google.common.truth.Truth.assertThat; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.google.gerrit.entities.Project; +import com.google.gerrit.entities.Project.NameKey; +import com.google.gerrit.extensions.registration.DynamicItem; +import com.google.gerrit.server.events.Event; +import com.google.gerrit.server.events.EventDispatcher; +import com.google.gerrit.server.project.ProjectCache; +import com.google.gerrit.server.project.ProjectState; +import com.google.gerrit.server.restapi.project.DeleteRef; +import com.googlesource.gerrit.plugins.replication.pull.FetchRefReplicatedEvent; +import com.googlesource.gerrit.plugins.replication.pull.PullReplicationStateLogger; +import java.util.Optional; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; + +@RunWith(MockitoJUnitRunner.class) +public class DeleteRefCommandTest { + private static final String TEST_SOURCE_LABEL = "test-source-label"; + private static final String TEST_REF_NAME = "refs/changes/01/1/1"; + private static final NameKey TEST_PROJECT_NAME = Project.nameKey("test-project"); + + @Mock private PullReplicationStateLogger fetchStateLog; + @Mock private DynamicItem<EventDispatcher> eventDispatcherDataItem; + @Mock private EventDispatcher eventDispatcher; + @Mock private ProjectCache projectCache; + @Mock private DeleteRef deleteRef; + @Mock private ProjectState projectState; + @Captor ArgumentCaptor<Event> eventCaptor; + + private DeleteRefCommand objectUnderTest; + + @Before + public void setup() { + when(eventDispatcherDataItem.get()).thenReturn(eventDispatcher); + when(projectCache.get(any())).thenReturn(Optional.of(projectState)); + + objectUnderTest = + new DeleteRefCommand(fetchStateLog, projectCache, deleteRef, eventDispatcherDataItem); + } + + @Test + public void shouldSendEventWhenDeletingRef() throws Exception { + objectUnderTest.deleteRef(TEST_PROJECT_NAME, TEST_REF_NAME, TEST_SOURCE_LABEL); + + verify(eventDispatcher).postEvent(eventCaptor.capture()); + Event sentEvent = eventCaptor.getValue(); + assertThat(sentEvent).isInstanceOf(FetchRefReplicatedEvent.class); + FetchRefReplicatedEvent fetchEvent = (FetchRefReplicatedEvent) sentEvent; + assertThat(fetchEvent.getProjectNameKey()).isEqualTo(TEST_PROJECT_NAME); + assertThat(fetchEvent.getRefName()).isEqualTo(TEST_REF_NAME); + } +}
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/FetchActionIT.java b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/FetchActionIT.java index 7b73df7..4ad8ce6 100644 --- a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/FetchActionIT.java +++ b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/FetchActionIT.java
@@ -76,9 +76,8 @@ } @Override - protected String getURL() { + protected String getURL(String projectName) { return String.format( - "%s/a/projects/%s/pull-replication~fetch", - adminRestSession.url(), Url.encode(project.get())); + "%s/a/projects/%s/pull-replication~fetch", adminRestSession.url(), Url.encode(projectName)); } }
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/ProjectDeletionActionIT.java b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/ProjectDeletionActionIT.java new file mode 100644 index 0000000..f5c8d4c --- /dev/null +++ b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/ProjectDeletionActionIT.java
@@ -0,0 +1,161 @@ +// Copyright (C) 2021 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.googlesource.gerrit.plugins.replication.pull.api; + +import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowCapability; + +import com.google.gerrit.acceptance.config.GerritConfig; +import com.google.gerrit.acceptance.testsuite.project.ProjectOperations; +import com.google.gerrit.extensions.restapi.Url; +import com.google.gerrit.server.group.SystemGroupBackend; +import com.google.inject.Inject; +import javax.servlet.http.HttpServletResponse; +import org.junit.Test; + +public class ProjectDeletionActionIT extends ActionITBase { + public static final String INVALID_TEST_PROJECT_NAME = "\0"; + public static final String DELETE_PROJECT_PERMISSION = "delete-project-deleteProject"; + + @Inject private ProjectOperations projectOperations; + + @Test + public void shouldReturnUnauthorizedForUserWithoutPermissions() throws Exception { + httpClientFactory + .create(source) + .execute( + createDeleteRequest(), + assertHttpResponseCode(HttpServletResponse.SC_UNAUTHORIZED), + getAnonymousContext()); + } + + @Test + public void shouldDeleteRepositoryWhenUserHasProjectDeletionCapabilities() throws Exception { + String testProjectName = project.get(); + url = getURL(testProjectName); + httpClientFactory + .create(source) + .execute( + createDeleteRequest(), + assertHttpResponseCode(HttpServletResponse.SC_FORBIDDEN), + getUserContext()); + + projectOperations + .project(allProjects) + .forUpdate() + .add(allowCapability(DELETE_PROJECT_PERMISSION).group(SystemGroupBackend.REGISTERED_USERS)) + .update(); + + httpClientFactory + .create(source) + .execute( + createDeleteRequest(), + assertHttpResponseCode(HttpServletResponse.SC_OK), + getUserContext()); + } + + @Test + public void shouldReturnOKWhenProjectIsDeleted() throws Exception { + String testProjectName = project.get(); + url = getURL(testProjectName); + + httpClientFactory + .create(source) + .execute( + createDeleteRequest(), assertHttpResponseCode(HttpServletResponse.SC_OK), getContext()); + } + + @Test + public void shouldReturnInternalServerErrorIfProjectCannotBeDeleted() throws Exception { + url = getURL(INVALID_TEST_PROJECT_NAME); + + httpClientFactory + .create(source) + .execute( + createDeleteRequest(), + assertHttpResponseCode(HttpServletResponse.SC_INTERNAL_SERVER_ERROR), + getContext()); + } + + @Test + @GerritConfig(name = "container.replica", value = "true") + public void shouldReturnUnauthorizedForUserWithoutPermissionsOnReplica() throws Exception { + httpClientFactory + .create(source) + .execute( + createDeleteRequest(), + assertHttpResponseCode(HttpServletResponse.SC_UNAUTHORIZED), + getAnonymousContext()); + } + + @Test + @GerritConfig(name = "container.replica", value = "true") + public void shouldReturnOKWhenProjectIsDeletedOnReplica() throws Exception { + String testProjectName = project.get(); + url = getURL(testProjectName); + + httpClientFactory + .create(source) + .execute( + createDeleteRequest(), assertHttpResponseCode(HttpServletResponse.SC_OK), getContext()); + } + + @Test + @GerritConfig(name = "container.replica", value = "true") + public void shouldDeleteRepositoryWhenUserHasProjectDeletionCapabilitiesAndNodeIsAReplica() + throws Exception { + String testProjectName = project.get(); + url = getURL(testProjectName); + httpClientFactory + .create(source) + .execute( + createDeleteRequest(), + assertHttpResponseCode(HttpServletResponse.SC_FORBIDDEN), + getUserContext()); + + projectOperations + .project(allProjects) + .forUpdate() + .add(allowCapability(DELETE_PROJECT_PERMISSION).group(SystemGroupBackend.REGISTERED_USERS)) + .update(); + + httpClientFactory + .create(source) + .execute( + createDeleteRequest(), + assertHttpResponseCode(HttpServletResponse.SC_OK), + getUserContext()); + } + + @Test + @GerritConfig(name = "container.replica", value = "true") + public void shouldReturnInternalServerErrorIfProjectCannotBeDeletedWhenNodeIsAReplica() + throws Exception { + url = getURL(INVALID_TEST_PROJECT_NAME); + + httpClientFactory + .create(source) + .execute( + createDeleteRequest(), + assertHttpResponseCode(HttpServletResponse.SC_INTERNAL_SERVER_ERROR), + getContext()); + } + + @Override + protected String getURL(String projectName) { + return String.format( + "%s/a/projects/%s/pull-replication~delete-project", + adminRestSession.url(), Url.encode(projectName)); + } +}
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/ProjectInitializationActionIT.java b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/ProjectInitializationActionIT.java new file mode 100644 index 0000000..ca4fa31 --- /dev/null +++ b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/ProjectInitializationActionIT.java
@@ -0,0 +1,221 @@ +// Copyright (C) 2021 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.googlesource.gerrit.plugins.replication.pull.api; + +import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowCapability; +import static com.googlesource.gerrit.plugins.replication.pull.api.ProjectInitializationAction.getProjectInitializationUrl; + +import com.google.common.net.MediaType; +import com.google.gerrit.acceptance.config.GerritConfig; +import com.google.gerrit.acceptance.testsuite.project.ProjectOperations; +import com.google.gerrit.common.data.GlobalCapability; +import com.google.gerrit.extensions.restapi.Url; +import com.google.gerrit.server.group.SystemGroupBackend; +import com.google.inject.Inject; +import javax.servlet.http.HttpServletResponse; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.client.methods.HttpPut; +import org.apache.http.message.BasicHeader; +import org.junit.Test; + +public class ProjectInitializationActionIT extends ActionITBase { + public static final String INVALID_TEST_PROJECT_NAME = "\0"; + @Inject private ProjectOperations projectOperations; + + @Test + public void shouldReturnUnauthorizedForUserWithoutPermissions() throws Exception { + httpClientFactory + .create(source) + .execute( + createPutRequestWithHeaders(), + assertHttpResponseCode(HttpServletResponse.SC_UNAUTHORIZED), + getAnonymousContext()); + } + + @Test + public void shouldReturnBadRequestIfContentNotSet() throws Exception { + httpClientFactory + .create(source) + .execute( + createPutRequestWithoutHeaders(), + assertHttpResponseCode(HttpServletResponse.SC_BAD_REQUEST), + getContext()); + } + + @Test + public void shouldCreateRepository() throws Exception { + String newProjectName = "new/newProjectForPrimary"; + url = getURL(newProjectName); + httpClientFactory + .create(source) + .execute( + createPutRequestWithHeaders(), + assertHttpResponseCode(HttpServletResponse.SC_CREATED), + getContext()); + + HttpGet getNewProjectRequest = + new HttpGet(userRestSession.url() + "/a/projects/" + Url.encode(newProjectName)); + httpClientFactory + .create(source) + .execute( + getNewProjectRequest, assertHttpResponseCode(HttpServletResponse.SC_OK), getContext()); + } + + @Test + public void shouldCreateRepositoryWhenUserHasProjectCreationCapabilities() throws Exception { + String newProjectName = "new/newProjectForUserWithCapabilities"; + url = getURL(newProjectName); + httpClientFactory + .create(source) + .execute( + createPutRequestWithHeaders(), + assertHttpResponseCode(HttpServletResponse.SC_FORBIDDEN), + getUserContext()); + + projectOperations + .project(allProjects) + .forUpdate() + .add( + allowCapability(GlobalCapability.CREATE_PROJECT) + .group(SystemGroupBackend.REGISTERED_USERS)) + .update(); + + httpClientFactory + .create(source) + .execute( + createPutRequestWithHeaders(), + assertHttpResponseCode(HttpServletResponse.SC_CREATED), + getUserContext()); + } + + @Test + public void shouldReturnForbiddenIfUserNotAuthorized() throws Exception { + httpClientFactory + .create(source) + .execute( + createPutRequestWithHeaders(), + assertHttpResponseCode(HttpServletResponse.SC_FORBIDDEN), + getUserContext()); + } + + @Test + @GerritConfig(name = "container.replica", value = "true") + public void shouldCreateRepositoryWhenNodeIsAReplica() throws Exception { + String newProjectName = "new/newProjectForReplica"; + url = getURL(newProjectName); + httpClientFactory + .create(source) + .execute( + createPutRequestWithHeaders(), + assertHttpResponseCode(HttpServletResponse.SC_CREATED), + getContext()); + } + + @Test + @GerritConfig(name = "container.replica", value = "true") + public void shouldReturnForbiddenIfUserNotAuthorizedAndNodeIsAReplica() throws Exception { + httpClientFactory + .create(source) + .execute( + createPutRequestWithHeaders(), + assertHttpResponseCode(HttpServletResponse.SC_FORBIDDEN), + getUserContext()); + } + + @Test + @GerritConfig(name = "container.replica", value = "true") + public void shouldCreateRepositoryWhenUserHasProjectCreationCapabilitiesAndNodeIsAReplica() + throws Exception { + String newProjectName = "new/newProjectForUserWithCapabilitiesReplica"; + url = getURL(newProjectName); + httpClientFactory + .create(source) + .execute( + createPutRequestWithHeaders(), + assertHttpResponseCode(HttpServletResponse.SC_FORBIDDEN), + getUserContext()); + + projectOperations + .project(allProjects) + .forUpdate() + .add( + allowCapability(GlobalCapability.CREATE_PROJECT) + .group(SystemGroupBackend.REGISTERED_USERS)) + .update(); + + httpClientFactory + .create(source) + .execute( + createPutRequestWithHeaders(), + assertHttpResponseCode(HttpServletResponse.SC_CREATED), + getUserContext()); + } + + @Test + @GerritConfig(name = "container.replica", value = "true") + public void shouldReturnInternalServerErrorIfProjectCannotBeCreatedWhenNodeIsAReplica() + throws Exception { + url = getURL(INVALID_TEST_PROJECT_NAME); + + httpClientFactory + .create(source) + .execute( + createPutRequestWithHeaders(), + assertHttpResponseCode(HttpServletResponse.SC_INTERNAL_SERVER_ERROR), + getContext()); + } + + @Test + @GerritConfig(name = "container.replica", value = "true") + public void shouldReturnBadRequestIfContentNotSetWhenNodeIsAReplica() throws Exception { + httpClientFactory + .create(source) + .execute( + createPutRequestWithoutHeaders(), + assertHttpResponseCode(HttpServletResponse.SC_BAD_REQUEST), + getContext()); + } + + @Test + @GerritConfig(name = "container.replica", value = "true") + public void shouldReturnUnauthorizedForUserWithoutPermissionsWhenNodeIsAReplica() + throws Exception { + httpClientFactory + .create(source) + .execute( + createPutRequestWithHeaders(), + assertHttpResponseCode(HttpServletResponse.SC_UNAUTHORIZED), + getAnonymousContext()); + } + + @Override + protected String getURL(String projectName) { + return userRestSession.url() + + "/" + + getProjectInitializationUrl("pull-replication", Url.encode(projectName)); + } + + protected HttpPut createPutRequestWithHeaders() { + HttpPut put = createPutRequestWithoutHeaders(); + put.addHeader(new BasicHeader("Accept", MediaType.ANY_TEXT_TYPE.toString())); + put.addHeader(new BasicHeader("Content-Type", MediaType.PLAIN_TEXT_UTF_8.toString())); + return put; + } + + protected HttpPut createPutRequestWithoutHeaders() { + HttpPut put = new HttpPut(url); + return put; + } +}
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/UpdateHeadActionIT.java b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/UpdateHeadActionIT.java new file mode 100644 index 0000000..8006064 --- /dev/null +++ b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/UpdateHeadActionIT.java
@@ -0,0 +1,167 @@ +// Copyright (C) 2021 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.googlesource.gerrit.plugins.replication.pull.api; + +import static com.google.common.truth.Truth.assertThat; +import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow; +import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS; + +import com.google.gerrit.acceptance.config.GerritConfig; +import com.google.gerrit.acceptance.testsuite.project.ProjectOperations; +import com.google.gerrit.common.data.Permission; +import com.google.gerrit.extensions.api.projects.BranchInput; +import com.google.gerrit.extensions.api.projects.HeadInput; +import com.google.gson.Gson; +import com.google.inject.Inject; +import javax.servlet.http.HttpServletResponse; +import org.junit.Test; + +public class UpdateHeadActionIT extends ActionITBase { + private static final Gson gson = newGson(); + + @Inject private ProjectOperations projectOperations; + + @Test + public void shouldReturnUnauthorizedForUserWithoutPermissions() throws Exception { + httpClientFactory + .create(source) + .execute( + createPutRequest(headInput("some/branch")), + assertHttpResponseCode(HttpServletResponse.SC_UNAUTHORIZED), + getAnonymousContext()); + } + + @Test + public void shouldReturnBadRequestWhenInputIsEmpty() throws Exception { + httpClientFactory + .create(source) + .execute( + createPutRequest(headInput("")), + assertHttpResponseCode(HttpServletResponse.SC_BAD_REQUEST), + getContext()); + } + + @Test + public void shouldReturnOKWhenHeadIsUpdated() throws Exception { + String testProjectName = project.get(); + String newBranch = "refs/heads/mybranch"; + String master = "refs/heads/master"; + BranchInput input = new BranchInput(); + input.revision = master; + gApi.projects().name(testProjectName).branch(newBranch).create(input); + + httpClientFactory + .create(source) + .execute( + createPutRequest(headInput(newBranch)), + assertHttpResponseCode(HttpServletResponse.SC_OK), + getContext()); + + assertThat(gApi.projects().name(testProjectName).head()).isEqualTo(newBranch); + } + + @Test + @GerritConfig(name = "container.replica", value = "true") + public void shouldReturnBadRequestWhenInputIsEmptyInReplica() throws Exception { + httpClientFactory + .create(source) + .execute( + createPutRequest(headInput("")), + assertHttpResponseCode(HttpServletResponse.SC_BAD_REQUEST), + getContext()); + } + + @Test + @GerritConfig(name = "container.replica", value = "true") + public void shouldReturnOKWhenHeadIsUpdatedInReplica() throws Exception { + String testProjectName = project.get(); + String newBranch = "refs/heads/mybranch"; + String master = "refs/heads/master"; + BranchInput input = new BranchInput(); + input.revision = master; + gApi.projects().name(testProjectName).branch(newBranch).create(input); + + httpClientFactory + .create(source) + .execute( + createPutRequest(headInput(newBranch)), + assertHttpResponseCode(HttpServletResponse.SC_OK), + getContext()); + + assertThat(gApi.projects().name(testProjectName).head()).isEqualTo(newBranch); + } + + @Test + public void shouldReturnForbiddenWhenMissingPermissions() throws Exception { + httpClientFactory + .create(source) + .execute( + createPutRequest(headInput("some/new/head")), + assertHttpResponseCode(HttpServletResponse.SC_FORBIDDEN), + getUserContext()); + } + + @Test + public void shouldReturnOKWhenRegisteredUserHasPermissions() throws Exception { + String testProjectName = project.get(); + String newBranch = "refs/heads/mybranch"; + String master = "refs/heads/master"; + BranchInput input = new BranchInput(); + input.revision = master; + gApi.projects().name(testProjectName).branch(newBranch).create(input); + + httpClientFactory + .create(source) + .execute( + createPutRequest(headInput(newBranch)), + assertHttpResponseCode(HttpServletResponse.SC_FORBIDDEN), + getUserContext()); + + projectOperations + .project(project) + .forUpdate() + .add(allow(Permission.OWNER).ref("refs/*").group(REGISTERED_USERS)) + .update(); + + httpClientFactory + .create(source) + .execute( + createPutRequest(headInput(newBranch)), + assertHttpResponseCode(HttpServletResponse.SC_OK), + getUserContext()); + } + + @Test + @GerritConfig(name = "container.replica", value = "true") + public void shouldReturnForbiddenWhenMissingPermissionsInReplica() throws Exception { + httpClientFactory + .create(source) + .execute( + createPutRequest(headInput("some/new/head")), + assertHttpResponseCode(HttpServletResponse.SC_FORBIDDEN), + getUserContext()); + } + + private String headInput(String ref) { + HeadInput headInput = new HeadInput(); + headInput.ref = ref; + return gson.toJson(headInput); + } + + @Override + protected String getURL(String projectName) { + return String.format("%s/a/projects/%s/HEAD", adminRestSession.url(), projectName); + } +}
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/client/FetchRestApiClientTest.java b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/client/FetchRestApiClientTest.java index a3b0b02..a1c2172 100644 --- a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/client/FetchRestApiClientTest.java +++ b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/client/FetchRestApiClientTest.java
@@ -23,7 +23,9 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import com.google.common.base.Charsets; import com.google.common.collect.Lists; +import com.google.common.io.CharStreams; import com.google.gerrit.entities.Project; import com.google.gerrit.entities.RefNames; import com.googlesource.gerrit.plugins.replication.CredentialsFactory; @@ -33,12 +35,15 @@ import com.googlesource.gerrit.plugins.replication.pull.api.data.RevisionObjectData; import com.googlesource.gerrit.plugins.replication.pull.filter.SyncRefsFilter; import java.io.IOException; +import java.io.InputStreamReader; import java.net.URISyntaxException; import java.nio.ByteBuffer; import java.util.Optional; import org.apache.http.Header; import org.apache.http.client.ClientProtocolException; +import org.apache.http.client.methods.HttpDelete; import org.apache.http.client.methods.HttpPost; +import org.apache.http.client.methods.HttpPut; import org.apache.http.message.BasicHeader; import org.eclipse.jgit.lib.Constants; import org.eclipse.jgit.storage.file.FileBasedConfig; @@ -59,6 +64,8 @@ @RunWith(MockitoJUnitRunner.class) public class FetchRestApiClientTest { + private static final boolean IS_REF_UPDATE = false; + @Mock CredentialsProvider credentialProvider; @Mock CredentialsFactory credentials; @Mock HttpClient httpClient; @@ -67,6 +74,8 @@ @Mock ReplicationFileBasedConfig replicationConfig; @Mock Source source; @Captor ArgumentCaptor<HttpPost> httpPostCaptor; + @Captor ArgumentCaptor<HttpPut> httpPutCaptor; + @Captor ArgumentCaptor<HttpDelete> httpDeleteCaptor; String api = "http://gerrit-host"; String pluginName = "pull-replication"; @@ -126,7 +135,7 @@ + " ]\n" + "}"; - FetchRestApiClient objectUnderTest; + FetchApiClient objectUnderTest; @Before public void setup() throws ClientProtocolException, IOException { @@ -266,7 +275,11 @@ throws ClientProtocolException, IOException, URISyntaxException { objectUnderTest.callSendObject( - Project.nameKey("test_repo"), refName, createSampleRevisionData(), new URIish(api)); + Project.nameKey("test_repo"), + refName, + IS_REF_UPDATE, + createSampleRevisionData(), + new URIish(api)); verify(httpClient, times(1)).execute(httpPostCaptor.capture(), any(), any()); @@ -281,7 +294,11 @@ throws ClientProtocolException, IOException, URISyntaxException { objectUnderTest.callSendObject( - Project.nameKey("test_repo"), refName, createSampleRevisionData(), new URIish(api)); + Project.nameKey("test_repo"), + refName, + IS_REF_UPDATE, + createSampleRevisionData(), + new URIish(api)); verify(httpClient, times(1)).execute(httpPostCaptor.capture(), any(), any()); @@ -347,6 +364,51 @@ source)); } + @Test + public void shouldCallInitProjectEndpoint() throws IOException, URISyntaxException { + + objectUnderTest.initProject(Project.nameKey("test_repo"), new URIish(api)); + + verify(httpClient, times(1)).execute(httpPutCaptor.capture(), any(), any()); + + HttpPut httpPut = httpPutCaptor.getValue(); + assertThat(httpPut.getURI().getHost()).isEqualTo("gerrit-host"); + assertThat(httpPut.getURI().getPath()) + .isEqualTo("/a/plugins/pull-replication/init-project/test_repo.git"); + } + + @Test + public void shouldCallDeleteProjectEndpoint() throws IOException, URISyntaxException { + + objectUnderTest.deleteProject(Project.nameKey("test_repo"), new URIish(api)); + + verify(httpClient, times(1)).execute(httpDeleteCaptor.capture(), any(), any()); + + HttpDelete httpDelete = httpDeleteCaptor.getValue(); + assertThat(httpDelete.getURI().getHost()).isEqualTo("gerrit-host"); + assertThat(httpDelete.getURI().getPath()) + .isEqualTo("/a/projects/test_repo/pull-replication~delete-project"); + } + + @Test + public void shouldCallUpdateHEADEndpoint() throws IOException, URISyntaxException { + String newHead = "newHead"; + String projectName = "aProject"; + objectUnderTest.updateHead(Project.nameKey(projectName), newHead, new URIish(api)); + + verify(httpClient, times(1)).execute(httpPutCaptor.capture(), any(), any()); + + HttpPut httpPut = httpPutCaptor.getValue(); + String payload = + CharStreams.toString( + new InputStreamReader(httpPut.getEntity().getContent(), Charsets.UTF_8)); + + assertThat(httpPut.getURI().getHost()).isEqualTo("gerrit-host"); + assertThat(httpPut.getURI().getPath()) + .isEqualTo(String.format("/a/projects/%s/pull-replication~HEAD", projectName)); + assertThat(payload).isEqualTo(String.format("{\"ref\": \"%s\"}", newHead)); + } + public String readPayload(HttpPost entity) throws UnsupportedOperationException, IOException { ByteBuffer buf = IO.readWholeStream(entity.getEntity().getContent(), 1024); return RawParseUtils.decode(buf.array(), buf.arrayOffset(), buf.limit()).trim();
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/fetch/ApplyObjectIT.java b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/fetch/ApplyObjectIT.java index f6cd342..0f063a4 100644 --- a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/fetch/ApplyObjectIT.java +++ b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/fetch/ApplyObjectIT.java
@@ -29,6 +29,7 @@ import com.google.gerrit.entities.Change; import com.google.gerrit.entities.Patch; import com.google.gerrit.entities.Project; +import com.google.gerrit.entities.Project.NameKey; import com.google.gerrit.entities.RefNames; import com.google.gerrit.extensions.api.changes.ReviewInput; import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput; @@ -81,12 +82,33 @@ RefSpec refSpec = new RefSpec(refName); objectUnderTest.apply(project, refSpec, revisionData.get()); - Optional<RevisionData> newRevisionData = reader.read(project, refName); - - compareObjects(revisionData.get(), newRevisionData); - try (Repository repo = repoManager.openRepository(project); TestRepository<Repository> testRepo = new TestRepository<>(repo); ) { + Optional<RevisionData> newRevisionData = + reader.read(project, repo.exactRef(refName).getObjectId(), refName); + compareObjects(revisionData.get(), newRevisionData); + testRepo.fsck(); + } + } + + @Test + public void shouldApplyRefSequencesChanges() throws Exception { + String testRepoProjectName = project + TEST_REPLICATION_SUFFIX; + testRepo = cloneProject(createTestProject(testRepoProjectName)); + + createChange(); + String seqChangesRef = RefNames.REFS_SEQUENCES + "changes"; + + Optional<RevisionData> revisionData = reader.read(allProjects, seqChangesRef); + + RefSpec refSpec = new RefSpec(seqChangesRef); + objectUnderTest.apply(project, refSpec, revisionData.get()); + try (Repository repo = repoManager.openRepository(project); + TestRepository<Repository> testRepo = new TestRepository<>(repo); ) { + + Optional<RevisionData> newRevisionData = + reader.read(project, repo.exactRef(seqChangesRef).getObjectId(), seqChangesRef); + compareObjects(revisionData.get(), newRevisionData); testRepo.fsck(); } } @@ -101,26 +123,30 @@ String refName = RefNames.changeMetaRef(changeId); RefSpec refSpec = new RefSpec(refName); - Optional<RevisionData> revisionData = - reader.read(Project.nameKey(testRepoProjectName), refName); - objectUnderTest.apply(project, refSpec, revisionData.get()); + NameKey testRepoKey = Project.nameKey(testRepoProjectName); + try (Repository repo = repoManager.openRepository(testRepoKey)) { + Optional<RevisionData> revisionData = + reader.read(testRepoKey, repo.exactRef(refName).getObjectId(), refName); + objectUnderTest.apply(project, refSpec, revisionData.get()); + } ReviewInput reviewInput = new ReviewInput(); CommentInput comment = createCommentInput(1, 0, 1, 1, "Test comment"); reviewInput.comments = ImmutableMap.of(Patch.COMMIT_MSG, ImmutableList.of(comment)); gApi.changes().id(changeId.get()).current().review(reviewInput); - Optional<RevisionData> revisionDataWithComment = - reader.read(Project.nameKey(testRepoProjectName), refName); - - objectUnderTest.apply(project, refSpec, revisionDataWithComment.get()); - - Optional<RevisionData> newRevisionData = reader.read(project, refName); - - compareObjects(revisionDataWithComment.get(), newRevisionData); - try (Repository repo = repoManager.openRepository(project); TestRepository<Repository> testRepo = new TestRepository<>(repo)) { + Optional<RevisionData> revisionDataWithComment = + reader.read(testRepoKey, repo.exactRef(refName).getObjectId(), refName); + + objectUnderTest.apply(project, refSpec, revisionDataWithComment.get()); + + Optional<RevisionData> newRevisionData = + reader.read(project, repo.exactRef(refName).getObjectId(), refName); + + compareObjects(revisionDataWithComment.get(), newRevisionData); + testRepo.fsck(); } } @@ -128,24 +154,27 @@ @Test public void shouldThrowExceptionWhenParentCommitObjectIsMissing() throws Exception { String testRepoProjectName = project + TEST_REPLICATION_SUFFIX; - testRepo = cloneProject(createTestProject(testRepoProjectName)); + NameKey createTestProject = createTestProject(testRepoProjectName); + try (Repository repo = repoManager.openRepository(createTestProject)) { + testRepo = cloneProject(createTestProject); - Result pushResult = createChange(); - Change.Id changeId = pushResult.getChange().getId(); - String refName = RefNames.changeMetaRef(changeId); + Result pushResult = createChange(); + Change.Id changeId = pushResult.getChange().getId(); + String refName = RefNames.changeMetaRef(changeId); - CommentInput comment = createCommentInput(1, 0, 1, 1, "Test comment"); - ReviewInput reviewInput = new ReviewInput(); - reviewInput.comments = ImmutableMap.of(Patch.COMMIT_MSG, ImmutableList.of(comment)); - gApi.changes().id(changeId.get()).current().review(reviewInput); + CommentInput comment = createCommentInput(1, 0, 1, 1, "Test comment"); + ReviewInput reviewInput = new ReviewInput(); + reviewInput.comments = ImmutableMap.of(Patch.COMMIT_MSG, ImmutableList.of(comment)); + gApi.changes().id(changeId.get()).current().review(reviewInput); - Optional<RevisionData> revisionData = - reader.read(Project.nameKey(testRepoProjectName), refName); + Optional<RevisionData> revisionData = + reader.read(createTestProject, repo.exactRef(refName).getObjectId(), refName); - RefSpec refSpec = new RefSpec(refName); - assertThrows( - MissingParentObjectException.class, - () -> objectUnderTest.apply(project, refSpec, revisionData.get())); + RefSpec refSpec = new RefSpec(refName); + assertThrows( + MissingParentObjectException.class, + () -> objectUnderTest.apply(project, refSpec, revisionData.get())); + } } private void compareObjects(RevisionData expected, Optional<RevisionData> actualOption) { @@ -165,6 +194,9 @@ } private void compareContent(RevisionObjectData expected, RevisionObjectData actual) { + if (expected == actual) { + return; + } assertThat(actual.getType()).isEqualTo(expected.getType()); assertThat(Bytes.asList(actual.getContent())) .containsExactlyElementsIn(Bytes.asList(expected.getContent()))