Merge branch 'stable-3.10' * stable-3.10: PushOne: Remove unused addRef() method TasksStorage: Remove synchronized from methods Place the replaying flag clearing in a finally Demote delete errors when distributor is enabled distributor: Reduce log level for no-op consolidations Change-Id: I3ce1519c44ec6cbae7725527ea626e55401cc488 Release-Notes: skip
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/AutoReloadConfigDecorator.java b/src/main/java/com/googlesource/gerrit/plugins/replication/AutoReloadConfigDecorator.java index 2c049fc..5e2c758 100644 --- a/src/main/java/com/googlesource/gerrit/plugins/replication/AutoReloadConfigDecorator.java +++ b/src/main/java/com/googlesource/gerrit/plugins/replication/AutoReloadConfigDecorator.java
@@ -128,4 +128,9 @@ public Config getConfig() { return currentConfig.getConfig(); } + + @Override + public boolean useLegacyCredentials() { + return currentConfig.useLegacyCredentials(); + } }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/AutoReloadSecureCredentialsFactoryDecorator.java b/src/main/java/com/googlesource/gerrit/plugins/replication/AutoReloadSecureCredentialsFactoryDecorator.java index e9409a6..32c439b 100644 --- a/src/main/java/com/googlesource/gerrit/plugins/replication/AutoReloadSecureCredentialsFactoryDecorator.java +++ b/src/main/java/com/googlesource/gerrit/plugins/replication/AutoReloadSecureCredentialsFactoryDecorator.java
@@ -14,49 +14,60 @@ package com.googlesource.gerrit.plugins.replication; -import static com.google.gerrit.common.FileUtil.lastModified; - import com.google.common.flogger.FluentLogger; +import com.google.gerrit.common.UsedAt; +import com.google.gerrit.common.UsedAt.Project; import com.google.gerrit.server.config.SitePaths; +import com.google.gerrit.server.securestore.SecureStore; import com.google.inject.Inject; import com.googlesource.gerrit.plugins.replication.api.ReplicationConfig; import java.io.IOException; -import java.nio.file.Files; import java.util.concurrent.atomic.AtomicReference; import org.eclipse.jgit.errors.ConfigInvalidException; import org.eclipse.jgit.transport.CredentialsProvider; +@UsedAt(Project.PLUGIN_PULL_REPLICATION) public class AutoReloadSecureCredentialsFactoryDecorator implements CredentialsFactory { private static final FluentLogger logger = FluentLogger.forEnclosingClass(); - private final AtomicReference<SecureCredentialsFactory> secureCredentialsFactory; - private volatile long secureCredentialsFactoryLoadTs; + private final AtomicReference<CredentialsFactory> secureCredentialsFactory; + private final SecureStore secureStore; private final SitePaths site; - private ReplicationConfig config; + private final ReplicationConfig config; @Inject - public AutoReloadSecureCredentialsFactoryDecorator(SitePaths site, ReplicationConfig config) + public AutoReloadSecureCredentialsFactoryDecorator( + SitePaths site, SecureStore secureStore, ReplicationConfig config) throws ConfigInvalidException, IOException { this.site = site; + this.secureStore = secureStore; this.config = config; - this.secureCredentialsFactory = new AtomicReference<>(new SecureCredentialsFactory(site)); - this.secureCredentialsFactoryLoadTs = getSecureConfigLastEditTs(); + this.secureCredentialsFactory = + new AtomicReference<>(newSecureCredentialsFactory(site, secureStore, config)); + if (config.useLegacyCredentials()) { + logger.atWarning().log( + "Using legacy credentials in clear text in secure.config. Please encrypt your credentials using " + + "'java -jar gerrit.war passwd' for each remote, remove the gerrit.useLegacyCredentials in replication.config " + + "and then reload the replication plugin."); + } } - private long getSecureConfigLastEditTs() { - if (!Files.exists(site.secure_config)) { - return 0L; + private static CredentialsFactory newSecureCredentialsFactory( + SitePaths site, SecureStore secureStore, ReplicationConfig config) + throws ConfigInvalidException, IOException { + if (config.useLegacyCredentials()) { + return new LegacyCredentialsFactory(site); } - return lastModified(site.secure_config); + return new SecureCredentialsFactory(secureStore); } @Override public CredentialsProvider create(String remoteName) { try { if (needsReload()) { + secureStore.reload(); secureCredentialsFactory.compareAndSet( - secureCredentialsFactory.get(), new SecureCredentialsFactory(site)); - secureCredentialsFactoryLoadTs = getSecureConfigLastEditTs(); + secureCredentialsFactory.get(), newSecureCredentialsFactory(site, secureStore, config)); logger.atInfo().log("secure.config reloaded as it was updated on the file system"); } } catch (Exception e) { @@ -69,7 +80,11 @@ } private boolean needsReload() { - return config.getConfig().getBoolean("gerrit", "autoReload", false) - && getSecureConfigLastEditTs() != secureCredentialsFactoryLoadTs; + return config.getConfig().getBoolean("gerrit", "autoReload", false) && secureStore.isOutdated(); + } + + @Override + public boolean validate(String remoteConfigName) { + return secureCredentialsFactory.get().validate(remoteConfigName); } }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/CredentialsFactory.java b/src/main/java/com/googlesource/gerrit/plugins/replication/CredentialsFactory.java index 3bb64ab..141fb2d 100644 --- a/src/main/java/com/googlesource/gerrit/plugins/replication/CredentialsFactory.java +++ b/src/main/java/com/googlesource/gerrit/plugins/replication/CredentialsFactory.java
@@ -18,4 +18,8 @@ public interface CredentialsFactory { CredentialsProvider create(String remoteName); + + default boolean validate(String remoteConfigName) { + return true; + } }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/Destination.java b/src/main/java/com/googlesource/gerrit/plugins/replication/Destination.java index c186493..3bb1d2d 100644 --- a/src/main/java/com/googlesource/gerrit/plugins/replication/Destination.java +++ b/src/main/java/com/googlesource/gerrit/plugins/replication/Destination.java
@@ -97,6 +97,8 @@ private static final String PROJECT_NOT_AVAILABLE = "source project %s not available"; + private final CredentialsFactory credentialsFactory; + public interface Factory { Destination create(DestinationConfiguration config); } @@ -149,6 +151,7 @@ GroupIncludeCache groupIncludeCache, DynamicItem<EventDispatcher> eventDispatcher, Provider<ReplicationTasksStorage> rts, + CredentialsFactory credentialsFactory, @Assisted DestinationConfiguration cfg) { this.eventDispatcher = eventDispatcher; gitManager = gitRepositoryManager; @@ -157,6 +160,7 @@ this.projectCache = projectCache; this.stateLog = stateLog; this.replicationTasksStorage = rts; + this.credentialsFactory = credentialsFactory; config = cfg; CurrentUser remoteUser; if (!cfg.getAuthGroupNames().isEmpty()) { @@ -216,6 +220,10 @@ threadScoper = child.getInstance(PerThreadRequestScope.Scoper.class); } + public boolean validate() { + return (credentialsFactory == null || credentialsFactory.validate(getRemoteConfigName())); + } + private void addRecursiveParents( AccountGroup.UUID g, ImmutableSet.Builder<AccountGroup.UUID> builder,
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/DestinationsCollection.java b/src/main/java/com/googlesource/gerrit/plugins/replication/DestinationsCollection.java index 8a41945..dc3b05e 100644 --- a/src/main/java/com/googlesource/gerrit/plugins/replication/DestinationsCollection.java +++ b/src/main/java/com/googlesource/gerrit/plugins/replication/DestinationsCollection.java
@@ -180,6 +180,12 @@ public synchronized void startup(WorkQueue workQueue) { shuttingDown = false; for (Destination cfg : destinations) { + if (!cfg.validate()) { + throw new IllegalStateException( + "Unable to start replication plugin because remote " + + cfg.getRemoteConfigName() + + " is not valid"); + } cfg.start(workQueue); } }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/FanoutConfigResource.java b/src/main/java/com/googlesource/gerrit/plugins/replication/FanoutConfigResource.java index 4220ddb..c634cce 100644 --- a/src/main/java/com/googlesource/gerrit/plugins/replication/FanoutConfigResource.java +++ b/src/main/java/com/googlesource/gerrit/plugins/replication/FanoutConfigResource.java
@@ -17,17 +17,20 @@ import static com.google.common.io.Files.getNameWithoutExtension; import static java.nio.charset.StandardCharsets.UTF_8; -import com.google.common.collect.Lists; import com.google.common.flogger.FluentLogger; import com.google.common.hash.Hasher; import com.google.common.hash.Hashing; import com.google.gerrit.server.config.SitePaths; import com.google.inject.Inject; +import java.io.File; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; -import java.util.Optional; +import java.util.List; import java.util.Set; +import java.util.function.Function; +import java.util.function.Predicate; +import java.util.stream.Collectors; import java.util.stream.Stream; import org.eclipse.jgit.errors.ConfigInvalidException; import org.eclipse.jgit.internal.storage.file.FileSnapshot; @@ -40,6 +43,7 @@ private static final FluentLogger logger = FluentLogger.forEnclosingClass(); private final Path remoteConfigsDirPath; + private final Config fanoutConfig; @Inject FanoutConfigResource(SitePaths site) throws IOException, ConfigInvalidException { @@ -48,16 +52,59 @@ removeRemotes(config); try (Stream<Path> files = Files.list(remoteConfigsDirPath)) { - files - .filter(Files::isRegularFile) - .filter(FanoutConfigResource::isConfig) - .map(FanoutConfigResource::loadConfig) - .filter(Optional::isPresent) - .map(Optional::get) - .filter(FanoutConfigResource::isValid) - .forEach(cfg -> addRemoteConfig(cfg, config)); - } catch (IllegalStateException e) { - throw new ConfigInvalidException(e.getMessage()); + Stream<String> mergedConfigs = + files + .filter(Files::isRegularFile) + .filter(FanoutConfigResource::isConfig) + .flatMap(path -> getConfigLines(path, line -> addRemoteSubsection(line, path))); + + fanoutConfig = new Config(config); + fanoutConfig.fromText(mergedConfigs.collect(Collectors.joining("\n"))); + } + } + + private static String addRemoteSubsection(String line, Path path) { + return line.contains("[remote]") + ? line.replace( + "[remote]", "[remote \"" + getNameWithoutExtension(path.toFile().getName()) + "\"]") + : line; + } + + private static Stream<String> getConfigLines( + Path path, Function<String, String> configLineMapping) { + try { + List<String> configLines = Files.readAllLines(path); + if (!isValid(path, configLines)) { + return Stream.empty(); + } + return configLines.stream().map(configLineMapping); + } catch (IOException e) { + logger.atSevere().withCause(e).log("Unable to access replication config %s", path); + return Stream.empty(); + } + } + + @Override + public void update(Config updates) throws IOException { + super.update(filterOutRemotes(updates)); + + Set<String> remotes = updates.getSubsections("remote"); + for (String remote : remotes) { + File remoteFile = remoteConfigsDirPath.resolve(remote + ".config").toFile(); + FileBasedConfig remoteConfig = new FileBasedConfig(remoteFile, FS.DETECTED); + try { + remoteConfig.load(); + } catch (ConfigInvalidException e) { + throw new IOException( + String.format("Cannot parse configuration file: %s", remoteFile.getAbsoluteFile()), e); + } + Set<String> options = updates.getNames("remote", remote); + for (String option : options) { + List<String> values = List.of(updates.getStringList("remote", remote, option)); + remoteConfig.setStringList("remote", null, option, values); + config.setStringList("remote", remote, option, values); + } + remoteConfig.save(); } } @@ -74,42 +121,67 @@ } } - private static void addRemoteConfig(FileBasedConfig source, Config destination) { - String remoteName = getNameWithoutExtension(source.getFile().getName()); - for (String name : source.getNames("remote")) { - destination.setStringList( - "remote", - remoteName, - name, - Lists.newArrayList(source.getStringList("remote", null, name))); - } + private static Config filterOutRemotes(Config config) { + Config filteredConfig = new Config(); + Set<String> sections = config.getSections(); + sections.stream() + .filter(Predicate.not("remote"::equalsIgnoreCase)) + .forEach( + section -> { + config + .getNames(section) + .forEach( + sectionName -> + filteredConfig.setStringList( + section, + null, + sectionName, + List.of(config.getStringList(section, null, sectionName)))); + config + .getSubsections(section) + .forEach( + subsection -> + config + .getNames(section, subsection) + .forEach( + name -> + filteredConfig.setStringList( + section, + subsection, + name, + List.of( + config.getStringList(section, subsection, name))))); + }); + return filteredConfig; } - private static boolean isValid(Config cfg) { - if (cfg.getSections().size() != 1 || !cfg.getSections().contains("remote")) { - logger.atSevere().log( - "Remote replication configuration file %s must contain only one remote section.", cfg); - return false; - } - if (cfg.getSubsections("remote").size() > 0) { - logger.atSevere().log( - "Remote replication configuration file %s cannot contain remote subsections.", cfg); - return false; + private static boolean isValid(Path path, List<String> remoteConfigLines) { + int numRemoteSections = 0; + int numRemoteSectionsWithName = 0; + boolean valid = true; + for (String configLine : remoteConfigLines) { + if (configLine.contains("[remote]")) { + numRemoteSections++; + } + + if (configLine.contains("[remote \"")) { + numRemoteSectionsWithName++; + } } - return true; - } - - private static Optional<FileBasedConfig> loadConfig(Path path) { - FileBasedConfig cfg = new FileBasedConfig(path.toFile(), FS.DETECTED); - try { - cfg.load(); - } catch (IOException | ConfigInvalidException e) { - logger.atSevere().withCause(e).log( - "Cannot load remote replication configuration file %s.", path); - return Optional.empty(); + if (numRemoteSectionsWithName > 0) { + logger.atSevere().log( + "Remote replication configuration file %s cannot contain remote subsections.", path); + valid = false; } - return Optional.of(cfg); + + if (numRemoteSections != 1) { + logger.atSevere().log( + "Remote replication configuration file %s must contain only one remote section.", path); + valid = false; + } + + return valid; } private static boolean isConfig(Path p) { @@ -140,4 +212,9 @@ return parentVersion; } } + + @Override + public Config getConfig() { + return fanoutConfig; + } }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/FileConfigResource.java b/src/main/java/com/googlesource/gerrit/plugins/replication/FileConfigResource.java index baccb83..7a14cd1 100644 --- a/src/main/java/com/googlesource/gerrit/plugins/replication/FileConfigResource.java +++ b/src/main/java/com/googlesource/gerrit/plugins/replication/FileConfigResource.java
@@ -17,6 +17,7 @@ import static com.googlesource.gerrit.plugins.replication.ReplicationQueue.repLog; import com.google.common.annotations.VisibleForTesting; +import com.google.common.collect.Lists; import com.google.gerrit.common.UsedAt; import com.google.gerrit.common.UsedAt.Project; import com.google.gerrit.server.config.SitePaths; @@ -24,6 +25,7 @@ import com.googlesource.gerrit.plugins.replication.api.ConfigResource; import java.io.IOException; import java.nio.file.Path; +import java.util.List; import org.eclipse.jgit.errors.ConfigInvalidException; import org.eclipse.jgit.lib.Config; import org.eclipse.jgit.storage.file.FileBasedConfig; @@ -54,6 +56,25 @@ } @Override + public void update(Config updates) throws IOException { + for (String section : updates.getSections()) { + for (String subsection : updates.getSubsections(section)) { + for (String name : updates.getNames(section, subsection, true)) { + List<String> values = + Lists.newArrayList(updates.getStringList(section, subsection, name)); + config.setStringList(section, subsection, name, values); + } + } + + for (String name : updates.getNames(section, true)) { + List<String> values = Lists.newArrayList(updates.getStringList(section, null, name)); + config.setStringList(section, null, name, values); + } + } + config.save(); + } + + @Override public String getVersion() { return Long.toString(config.getFile().lastModified()); }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/LegacyCredentialsFactory.java b/src/main/java/com/googlesource/gerrit/plugins/replication/LegacyCredentialsFactory.java new file mode 100644 index 0000000..a89c936 --- /dev/null +++ b/src/main/java/com/googlesource/gerrit/plugins/replication/LegacyCredentialsFactory.java
@@ -0,0 +1,66 @@ +// Copyright (C) 2011 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; + +import com.google.gerrit.server.config.SitePaths; +import com.google.inject.Inject; +import java.io.IOException; +import java.util.Objects; +import org.eclipse.jgit.errors.ConfigInvalidException; +import org.eclipse.jgit.lib.Config; +import org.eclipse.jgit.storage.file.FileBasedConfig; +import org.eclipse.jgit.transport.CredentialsProvider; +import org.eclipse.jgit.transport.UsernamePasswordCredentialsProvider; +import org.eclipse.jgit.util.FS; + +/** + * Looks up a remote's password in secure.config. + * + * @deprecated This class is not secure and should no longer be used; it was allowing to record and + * read credentials in clear text in secure.config even though the Gerrit administrator + * installed a proper SecureStore implementation such as the secure-config.jar libModule. + */ +@Deprecated(forRemoval = true) +class LegacyCredentialsFactory implements CredentialsFactory { + private final Config config; + + @Inject + public LegacyCredentialsFactory(SitePaths site) throws ConfigInvalidException, IOException { + config = load(site); + } + + private static Config load(SitePaths site) throws ConfigInvalidException, IOException { + FileBasedConfig cfg = new FileBasedConfig(site.secure_config.toFile(), FS.DETECTED); + if (cfg.getFile().exists() && cfg.getFile().length() > 0) { + try { + cfg.load(); + } catch (ConfigInvalidException e) { + throw new ConfigInvalidException( + String.format("Config file %s is invalid: %s", cfg.getFile(), e.getMessage()), e); + } catch (IOException e) { + throw new IOException( + String.format("Cannot read %s: %s", cfg.getFile(), e.getMessage()), e); + } + } + return cfg; + } + + @Override + public CredentialsProvider create(String remoteName) { + String user = Objects.toString(config.getString("remote", remoteName, "username"), ""); + String pass = Objects.toString(config.getString("remote", remoteName, "password"), ""); + return new UsernamePasswordCredentialsProvider(user, pass); + } +}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/MergedConfigResource.java b/src/main/java/com/googlesource/gerrit/plugins/replication/MergedConfigResource.java index 43d90a5..58a10f0 100644 --- a/src/main/java/com/googlesource/gerrit/plugins/replication/MergedConfigResource.java +++ b/src/main/java/com/googlesource/gerrit/plugins/replication/MergedConfigResource.java
@@ -26,6 +26,7 @@ import com.google.inject.util.Providers; import com.googlesource.gerrit.plugins.replication.api.ConfigResource; import com.googlesource.gerrit.plugins.replication.api.ReplicationConfigOverrides; +import java.io.IOException; import java.util.function.Supplier; import org.eclipse.jgit.errors.ConfigInvalidException; import org.eclipse.jgit.lib.Config; @@ -77,7 +78,15 @@ return baseVersion + overrides.get().getVersion(); } - private boolean noOverrides() { + boolean noOverrides() { return overrides == null || overrides.get() == null; } + + void update(Config remotesConfig) throws IOException { + if (noOverrides()) { + base.get().update(remotesConfig); + } else { + overrides.get().update(remotesConfig); + } + } }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/PushOne.java b/src/main/java/com/googlesource/gerrit/plugins/replication/PushOne.java index 2b60145..a648845 100644 --- a/src/main/java/com/googlesource/gerrit/plugins/replication/PushOne.java +++ b/src/main/java/com/googlesource/gerrit/plugins/replication/PushOne.java
@@ -79,7 +79,6 @@ import org.eclipse.jgit.lib.ObjectId; import org.eclipse.jgit.lib.Ref; import org.eclipse.jgit.lib.Repository; -import org.eclipse.jgit.transport.CredentialsProvider; import org.eclipse.jgit.transport.FetchConnection; import org.eclipse.jgit.transport.PushResult; import org.eclipse.jgit.transport.RefSpec; @@ -119,7 +118,7 @@ private final Destination pool; private final RemoteConfig config; private final ReplicationConfig replConfig; - private final CredentialsProvider credentialsProvider; + private final CredentialsFactory credentialsFactory; private final PerThreadRequestScope.Scoper threadScoper; private final Project.NameKey projectName; @@ -166,7 +165,7 @@ pool = p; config = c; replConfig = rc; - credentialsProvider = cpFactory.create(c.getName()); + credentialsFactory = cpFactory; threadScoper = ts; projectName = d; uri = u; @@ -563,7 +562,7 @@ private PushResult pushVia(Transport tn) throws IOException, PermissionBackendException { tn.applyConfig(config); - tn.setCredentialsProvider(credentialsProvider); + tn.setCredentialsProvider(credentialsFactory.create(config.getName())); List<RemoteRefUpdate> todo = generateUpdates(tn); if (todo.isEmpty()) {
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationConfigImpl.java b/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationConfigImpl.java index 1223db0..8f9b805 100644 --- a/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationConfigImpl.java +++ b/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationConfigImpl.java
@@ -31,6 +31,7 @@ private final SitePaths site; private final MergedConfigResource configResource; + private final boolean useLegacyCredentials; private boolean replicateAllOnPluginStart; private boolean defaultForceUpdate; private int maxRefsToLog; @@ -62,6 +63,7 @@ DEFAULT_SSH_CONNECTION_TIMEOUT_MS, MILLISECONDS); this.pluginDataDir = pluginDataDir; + this.useLegacyCredentials = config.getBoolean("gerrit", "useLegacyCredentials", false); } @Nullable @@ -125,6 +127,11 @@ } @Override + public boolean useLegacyCredentials() { + return useLegacyCredentials; + } + + @Override public String getVersion() { return configResource.getVersion(); }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationModule.java b/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationModule.java index ac27280..e3e4021 100644 --- a/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationModule.java +++ b/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationModule.java
@@ -23,6 +23,7 @@ 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.extensions.registration.DynamicSet; import com.google.gerrit.server.events.EventTypes; import com.google.inject.AbstractModule; @@ -30,6 +31,7 @@ import com.google.inject.Scopes; import com.google.inject.assistedinject.FactoryModuleBuilder; import com.google.inject.internal.UniqueAnnotations; +import com.googlesource.gerrit.plugins.replication.api.ReplicationRemotesApi; import com.googlesource.gerrit.plugins.replication.events.ProjectDeletionReplicationDoneEvent; import com.googlesource.gerrit.plugins.replication.events.ProjectDeletionReplicationFailedEvent; import com.googlesource.gerrit.plugins.replication.events.ProjectDeletionReplicationScheduledEvent; @@ -52,9 +54,7 @@ @Override protected void configure() { - install(new FactoryModuleBuilder().build(Destination.Factory.class)); install(configModule); - bind(ReplicationQueue.class).in(Scopes.SINGLETON); bind(ObservableQueue.class).to(ReplicationQueue.class); bind(LifecycleListener.class) .annotatedWith(UniqueAnnotations.create()) @@ -63,6 +63,7 @@ DynamicSet.bind(binder(), GitBatchRefUpdateListener.class).to(ReplicationQueue.class); DynamicSet.bind(binder(), ProjectDeletedListener.class).to(ReplicationQueue.class); DynamicSet.bind(binder(), HeadUpdatedListener.class).to(ReplicationQueue.class); + DynamicItem.bind(binder(), ReplicationRemotesApi.class).to(ReplicationRemotesApiImpl.class); bind(OnStartStop.class).in(Scopes.SINGLETON); bind(LifecycleListener.class).annotatedWith(UniqueAnnotations.create()).to(OnStartStop.class); @@ -77,10 +78,8 @@ .to(StartReplicationCapability.class); install(new FactoryModuleBuilder().build(PushAll.Factory.class)); - install(new FactoryModuleBuilder().build(ProjectDeletionState.Factory.class)); bind(EventBus.class).in(Scopes.SINGLETON); - bind(ReplicationDestinations.class).to(DestinationsCollection.class); bind(ConfigParser.class).to(DestinationConfigParser.class).in(Scopes.SINGLETON); DynamicSet.setOf(binder(), ReplicationStateListener.class); @@ -103,5 +102,11 @@ bind(TransportFactory.class).to(TransportFactoryImpl.class).in(Scopes.SINGLETON); bind(CloseableHttpClient.class).toProvider(HttpClientProvider.class).in(Scopes.SINGLETON); + + bind(ReplicationQueue.class).in(Scopes.SINGLETON); + bind(ReplicationDestinations.class).to(DestinationsCollection.class); + + install(new FactoryModuleBuilder().build(Destination.Factory.class)); + install(new FactoryModuleBuilder().build(ProjectDeletionState.Factory.class)); } }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationRemotesApiImpl.java b/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationRemotesApiImpl.java new file mode 100644 index 0000000..cc6bcad --- /dev/null +++ b/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationRemotesApiImpl.java
@@ -0,0 +1,100 @@ +// Copyright (C) 2024 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; + +import com.google.gerrit.server.securestore.SecureStore; +import com.google.inject.Inject; +import com.google.inject.Singleton; +import com.googlesource.gerrit.plugins.replication.api.ReplicationRemotesApi; +import java.io.IOException; +import java.util.Arrays; +import java.util.List; +import java.util.Set; +import org.eclipse.jgit.lib.Config; + +@Singleton +class ReplicationRemotesApiImpl implements ReplicationRemotesApi { + + private final SecureStore secureStore; + private final MergedConfigResource mergedConfigResource; + + @Inject + ReplicationRemotesApiImpl(SecureStore secureStore, MergedConfigResource mergedConfigResource) { + this.secureStore = secureStore; + this.mergedConfigResource = mergedConfigResource; + } + + @Override + public Config get(String... remoteNames) { + Config replicationConfig = mergedConfigResource.getConfig(); + Config remoteConfig = new Config(); + for (String remoteName : remoteNames) { + Set<String> configNames = replicationConfig.getNames("remote", remoteName); + for (String configName : configNames) { + String[] values = replicationConfig.getStringList("remote", remoteName, configName); + if (values.length > 0) { + remoteConfig.setStringList("remote", remoteName, configName, Arrays.asList(values)); + } + } + } + return remoteConfig; + } + + @Override + public void update(Config remoteConfig) throws IOException { + if (remoteConfig.getSubsections("remote").isEmpty()) { + throw new IllegalArgumentException( + "configuration update must have at least one 'remote' section"); + } + + SeparatedRemoteConfigs configs = onlyRemoteSectionsWithSeparatedCredentials(remoteConfig); + persistRemotesCredentials(configs); + + mergedConfigResource.update(configs.remotes); + } + + private SeparatedRemoteConfigs onlyRemoteSectionsWithSeparatedCredentials(Config configUpdates) { + SeparatedRemoteConfigs configs = new SeparatedRemoteConfigs(); + for (String subSection : configUpdates.getSubsections("remote")) { + for (String name : configUpdates.getNames("remote", subSection)) { + List<String> values = List.of(configUpdates.getStringList("remote", subSection, name)); + if ("password".equals(name) || "username".equals(name)) { + configs.credentials.setStringList("remote", subSection, name, values); + } else { + configs.remotes.setStringList("remote", subSection, name, values); + } + } + } + + return configs; + } + + private void persistRemotesCredentials(SeparatedRemoteConfigs configs) { + for (String subSection : configs.credentials.getSubsections("remote")) { + copyRemoteStringList(configs.credentials, subSection, "username"); + copyRemoteStringList(configs.credentials, subSection, "password"); + } + } + + private void copyRemoteStringList(Config config, String subSection, String key) { + secureStore.setList( + "remote", subSection, key, List.of(config.getStringList("remote", subSection, key))); + } + + private static class SeparatedRemoteConfigs { + private final Config remotes = new Config(); + private final Config credentials = new Config(); + } +}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/SecureCredentialsFactory.java b/src/main/java/com/googlesource/gerrit/plugins/replication/SecureCredentialsFactory.java index ed15b92..3e00089 100644 --- a/src/main/java/com/googlesource/gerrit/plugins/replication/SecureCredentialsFactory.java +++ b/src/main/java/com/googlesource/gerrit/plugins/replication/SecureCredentialsFactory.java
@@ -1,4 +1,4 @@ -// Copyright (C) 2011 The Android Open Source Project +// Copyright (C) 2024 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. @@ -14,46 +14,43 @@ package com.googlesource.gerrit.plugins.replication; -import com.google.gerrit.server.config.SitePaths; +import com.google.common.flogger.FluentLogger; +import com.google.gerrit.common.UsedAt; +import com.google.gerrit.common.UsedAt.Project; +import com.google.gerrit.server.securestore.SecureStore; import com.google.inject.Inject; -import java.io.IOException; import java.util.Objects; -import org.eclipse.jgit.errors.ConfigInvalidException; -import org.eclipse.jgit.lib.Config; -import org.eclipse.jgit.storage.file.FileBasedConfig; import org.eclipse.jgit.transport.CredentialsProvider; import org.eclipse.jgit.transport.UsernamePasswordCredentialsProvider; -import org.eclipse.jgit.util.FS; -/** Looks up a remote's password in secure.config. */ +/** Looks up a remote's password in SecureStore */ +@UsedAt(Project.PLUGIN_PULL_REPLICATION) public class SecureCredentialsFactory implements CredentialsFactory { - private final Config config; + private static final FluentLogger log = FluentLogger.forEnclosingClass(); + private final SecureStore secureStore; @Inject - public SecureCredentialsFactory(SitePaths site) throws ConfigInvalidException, IOException { - config = load(site); - } - - private static Config load(SitePaths site) throws ConfigInvalidException, IOException { - FileBasedConfig cfg = new FileBasedConfig(site.secure_config.toFile(), FS.DETECTED); - if (cfg.getFile().exists() && cfg.getFile().length() > 0) { - try { - cfg.load(); - } catch (ConfigInvalidException e) { - throw new ConfigInvalidException( - String.format("Config file %s is invalid: %s", cfg.getFile(), e.getMessage()), e); - } catch (IOException e) { - throw new IOException( - String.format("Cannot read %s: %s", cfg.getFile(), e.getMessage()), e); - } - } - return cfg; + public SecureCredentialsFactory(SecureStore secureStore) { + this.secureStore = secureStore; } @Override public CredentialsProvider create(String remoteName) { - String user = Objects.toString(config.getString("remote", remoteName, "username"), ""); - String pass = Objects.toString(config.getString("remote", remoteName, "password"), ""); + String user = Objects.toString(secureStore.get("remote", remoteName, "username"), ""); + String pass = Objects.toString(secureStore.get("remote", remoteName, "password"), ""); return new UsernamePasswordCredentialsProvider(user, pass); } + + @Override + public boolean validate(String remoteName) { + try { + String unused = secureStore.get("remote", remoteName, "username"); + unused = secureStore.get("remote", remoteName, "password"); + return true; + } catch (Throwable t) { + log.atSevere().withCause(t).log( + "Credentials for replication remote %s are invalid", remoteName); + return false; + } + } }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/api/ApiModule.java b/src/main/java/com/googlesource/gerrit/plugins/replication/api/ApiModule.java index 3764ede..44ec88c 100644 --- a/src/main/java/com/googlesource/gerrit/plugins/replication/api/ApiModule.java +++ b/src/main/java/com/googlesource/gerrit/plugins/replication/api/ApiModule.java
@@ -22,5 +22,6 @@ protected void configure() { DynamicItem.itemOf(binder(), ReplicationPushFilter.class); DynamicItem.itemOf(binder(), ReplicationConfigOverrides.class); + DynamicItem.itemOf(binder(), ReplicationRemotesApi.class); } }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/api/ConfigResource.java b/src/main/java/com/googlesource/gerrit/plugins/replication/api/ConfigResource.java index 43733bc..0052fc2 100644 --- a/src/main/java/com/googlesource/gerrit/plugins/replication/api/ConfigResource.java +++ b/src/main/java/com/googlesource/gerrit/plugins/replication/api/ConfigResource.java
@@ -14,6 +14,7 @@ package com.googlesource.gerrit.plugins.replication.api; +import java.io.IOException; import org.eclipse.jgit.lib.Config; /** @@ -37,6 +38,16 @@ Config getConfig(); /** + * Update the configuration resource. + * + * <p>Allows to persist changes to the configuration resource. + * + * @param config updated configuration + * @throws IOException when configuration cannot be persisted + */ + void update(Config config) throws IOException; + + /** * Current logical version string of the current configuration on the persistent storage. * * @return latest logical version number on the persistent storage
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/api/ReplicationConfig.java b/src/main/java/com/googlesource/gerrit/plugins/replication/api/ReplicationConfig.java index 8ef4e2b..d5ccb02 100644 --- a/src/main/java/com/googlesource/gerrit/plugins/replication/api/ReplicationConfig.java +++ b/src/main/java/com/googlesource/gerrit/plugins/replication/api/ReplicationConfig.java
@@ -102,4 +102,16 @@ * @return the config. */ Config getConfig(); + + /** + * Use legacy credentials for migrating existing sites. + * + * <p>Existing installations may have used a mix of encrypted and clear text credentials in + * secure.config, leveraging the replication plugin bug that was not accessing it using the + * correct API. The legacy feature flag 'gerrit.useLegacyCredentials' allows Gerrit to still use + * direct access to secure.config without decrypting its values. + * + * @return true if the secure.config should be read always directly and without decryption + */ + boolean useLegacyCredentials(); }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/api/ReplicationRemotesApi.java b/src/main/java/com/googlesource/gerrit/plugins/replication/api/ReplicationRemotesApi.java new file mode 100644 index 0000000..76d6d35 --- /dev/null +++ b/src/main/java/com/googlesource/gerrit/plugins/replication/api/ReplicationRemotesApi.java
@@ -0,0 +1,55 @@ +// Copyright (C) 2024 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.api; + +import static com.google.gerrit.common.UsedAt.Project.PLUGIN_GITHUB; + +import com.google.gerrit.common.UsedAt; +import com.google.gerrit.extensions.registration.DynamicItem; +import com.google.gerrit.server.securestore.SecureStore; +import java.io.IOException; +import org.eclipse.jgit.lib.Config; + +/** Public API to update replication plugin remotes configurations programmatically. */ +@UsedAt(PLUGIN_GITHUB) +@DynamicItem.Final(implementedByPlugin = "replication") +public interface ReplicationRemotesApi { + + /** + * Retrieves the configuration for a remote by name. + * + * <p>Builds a JGit {@link Config} with the remote's configuration obtained by parsing the + * replication configuration sources. + * + * <p>NOTE: The remotes secrets are excluded for security reasons. + * + * @param remoteNames the remote names to retrieve. + * @return {@link Config} associated with the remoteName(s) + */ + Config get(String... remoteNames); + + /** + * Adds or updates the remote configuration for the replication plugin. + * + * <p>Provided JGit {@link Config} object should contain at least one named <em>remote</em> + * section. All other configurations will be ignored. + * + * <p>NOTE: The {@code remote.$name.password} will be stored using {@link SecureStore}. + * + * @param remoteConfig remotes to add or update + * @throws IOException when persisting fails + */ + void update(Config remoteConfig) throws IOException; +}
diff --git a/src/main/resources/Documentation/config.md b/src/main/resources/Documentation/config.md index 6f9a49d..bb4be9a 100644 --- a/src/main/resources/Documentation/config.md +++ b/src/main/resources/Documentation/config.md
@@ -170,6 +170,14 @@ : Timeout for SSH connections. If 0, there is no timeout and the client waits indefinitely. By default, 2 minutes. +gerrit.useLegacyCredentials +: Use legacy credentials on secure.config which did not allow encryption + through a SecureStore provider. Legacy credentials should not be used in + production and are provided purely for backward compatibility with existing + secure.config files. When enabling the SecureStore in Gerrit, for instance, + the secure-config.jar libModule, make sure that all existing replication + credentials are encrypted. By default, false. + replication.distributionInterval : Interval in seconds for running the replication distributor. When run, the replication distributor will add all persisted waiting tasks
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/AbstractConfigTest.java b/src/test/java/com/googlesource/gerrit/plugins/replication/AbstractConfigTest.java index b8bbc08..935fdb4 100644 --- a/src/test/java/com/googlesource/gerrit/plugins/replication/AbstractConfigTest.java +++ b/src/test/java/com/googlesource/gerrit/plugins/replication/AbstractConfigTest.java
@@ -57,7 +57,8 @@ public final DestinationConfiguration config; protected FakeDestination(DestinationConfiguration config) { - super(injectorMock(), null, null, null, null, null, null, null, null, null, null, config); + super( + injectorMock(), null, null, null, null, null, null, null, null, null, null, null, config); this.config = config; } @@ -78,7 +79,7 @@ } @Before - public void setup() { + public void setup() throws IOException { when(destinationFactoryMock.create(any(DestinationConfiguration.class))) .thenAnswer( new Answer<Destination>() { @@ -103,6 +104,10 @@ return newReplicationConfig("replication.config"); } + protected FileBasedConfig newSecureConfig() { + return newReplicationConfig("secure.config"); + } + protected FileBasedConfig newReplicationConfig(String path) { FileBasedConfig replicationConfig = new FileBasedConfig(sitePaths.etc_dir.resolve(path).toFile(), FS.DETECTED);
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/AutoReloadSecureCredentialsFactoryDecoratorTest.java b/src/test/java/com/googlesource/gerrit/plugins/replication/AutoReloadSecureCredentialsFactoryDecoratorTest.java new file mode 100644 index 0000000..c959cc9 --- /dev/null +++ b/src/test/java/com/googlesource/gerrit/plugins/replication/AutoReloadSecureCredentialsFactoryDecoratorTest.java
@@ -0,0 +1,97 @@ +// Copyright (C) 2024 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; + +import static com.google.common.truth.Truth.assertThat; +import static org.mockito.Mockito.when; + +import com.google.gerrit.server.securestore.SecureStore; +import com.googlesource.gerrit.plugins.replication.api.ReplicationConfig; +import java.io.IOException; +import org.eclipse.jgit.errors.ConfigInvalidException; +import org.eclipse.jgit.lib.Config; +import org.eclipse.jgit.storage.file.FileBasedConfig; +import org.eclipse.jgit.transport.CredentialItem; +import org.eclipse.jgit.transport.CredentialsProvider; +import org.eclipse.jgit.transport.URIish; +import org.eclipse.jgit.transport.UsernamePasswordCredentialsProvider; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; + +@RunWith(MockitoJUnitRunner.class) +public class AutoReloadSecureCredentialsFactoryDecoratorTest extends AbstractConfigTest { + private static final String REMOTE_TEST = "remote-test"; + private static final String USERNAME_CLEARTEXT = "username-clear"; + private static final String PASSWORD_CLEARTEXT = "password-clear"; + private static final String USERNAME_CIPHERTEXT = "username-encrypted"; + private static final String PASSWORD_CIPHERTEXT = "password-encrypted"; + + @Mock private SecureStore secureStoreMock; + @Mock private ReplicationConfig replicationConfigMock; + + public AutoReloadSecureCredentialsFactoryDecoratorTest() throws IOException { + super(); + } + + @Override + @Before + public void setup() throws IOException { + when(replicationConfigMock.getConfig()).thenReturn(new Config()); + + when(secureStoreMock.get("remote", REMOTE_TEST, "username")).thenReturn(USERNAME_CIPHERTEXT); + when(secureStoreMock.get("remote", REMOTE_TEST, "password")).thenReturn(PASSWORD_CIPHERTEXT); + + FileBasedConfig secureConfig = newSecureConfig(); + secureConfig.setString("remote", REMOTE_TEST, "username", USERNAME_CLEARTEXT); + secureConfig.setString("remote", REMOTE_TEST, "password", PASSWORD_CLEARTEXT); + secureConfig.save(); + } + + @Test + public void shouldCreateLegacyCredentials() throws Exception { + when(replicationConfigMock.useLegacyCredentials()).thenReturn(true); + assertUsernamePasswordCredentials( + getCredentialsProvider(), USERNAME_CLEARTEXT, PASSWORD_CLEARTEXT); + } + + @Test + public void shouldCreateEncryptedCredentialsByDefault() throws Exception { + assertUsernamePasswordCredentials( + getCredentialsProvider(), USERNAME_CIPHERTEXT, PASSWORD_CIPHERTEXT); + } + + private UsernamePasswordCredentialsProvider getCredentialsProvider() + throws ConfigInvalidException, IOException { + AutoReloadSecureCredentialsFactoryDecorator credFactory = + new AutoReloadSecureCredentialsFactoryDecorator( + sitePaths, secureStoreMock, replicationConfigMock); + CredentialsProvider legacyCredentials = credFactory.create(REMOTE_TEST); + assertThat(legacyCredentials).isInstanceOf(UsernamePasswordCredentialsProvider.class); + return (UsernamePasswordCredentialsProvider) legacyCredentials; + } + + private static void assertUsernamePasswordCredentials( + UsernamePasswordCredentialsProvider credentials, String username, String password) { + CredentialItem.Username usernameItem = new CredentialItem.Username(); + CredentialItem.Password passwordItem = new CredentialItem.Password(); + assertThat(credentials.get(new URIish(), usernameItem, passwordItem)).isTrue(); + + assertThat(usernameItem.getValue()).isEqualTo(username); + assertThat(new String(passwordItem.getValue())).isEqualTo(password); + } +}
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/FanoutConfigResourceTest.java b/src/test/java/com/googlesource/gerrit/plugins/replication/FanoutConfigResourceTest.java index 9147ea1..a3d811f 100644 --- a/src/test/java/com/googlesource/gerrit/plugins/replication/FanoutConfigResourceTest.java +++ b/src/test/java/com/googlesource/gerrit/plugins/replication/FanoutConfigResourceTest.java
@@ -24,6 +24,7 @@ import java.io.IOException; import java.util.List; import org.eclipse.jgit.errors.ConfigInvalidException; +import org.eclipse.jgit.lib.Config; import org.eclipse.jgit.storage.file.FileBasedConfig; import org.eclipse.jgit.util.FS; import org.junit.Before; @@ -285,9 +286,73 @@ assertThat(objectUnderTest.getVersion()).isEqualTo(replicationConfigVersion); } + @Test + public void shouldAddConfigOptionToMainConfig() throws Exception { + FanoutConfigResource objectUnderTest = new FanoutConfigResource(sitePaths); + Config update = new Config(); + update.setString("new", null, "value", "set"); + + objectUnderTest.update(update); + Config updatedConfig = objectUnderTest.getConfig(); + + assertThat(updatedConfig.getString("new", null, "value")).isEqualTo("set"); + } + + @Test + public void shouldUpdateConfigOptionInMainConfig() throws Exception { + FileBasedConfig config = newReplicationConfig(); + config.setString("updatable", null, "value", "orig"); + config.save(); + FanoutConfigResource objectUnderTest = new FanoutConfigResource(sitePaths); + Config update = new Config(); + update.setString("updatable", null, "value", "updated"); + + objectUnderTest.update(update); + Config updatedConfig = objectUnderTest.getConfig(); + + assertThat(updatedConfig.getString("updatable", null, "value")).isEqualTo("updated"); + } + + @Test + public void shouldAddNewRemoteFile() throws Exception { + FanoutConfigResource objectUnderTest = new FanoutConfigResource(sitePaths); + Config update = new Config(); + update.setString("remote", remoteName1, "url", remoteUrl1); + + objectUnderTest.update(update); + + assertThat(objectUnderTest.getConfig().getString("remote", remoteName1, "url")) + .isEqualTo(remoteUrl1); + Config actual = loadRemoteConfig(remoteName1); + assertThat(actual.getString("remote", null, "url")).isEqualTo(remoteUrl1); + } + + @Test + public void shouldUpdateExistingRemote() throws Exception { + FileBasedConfig rawRemoteConfig = newRemoteConfig(remoteName1); + rawRemoteConfig.setString("remote", remoteName1, "url", remoteUrl1); + rawRemoteConfig.save(); + FanoutConfigResource objectUnderTest = new FanoutConfigResource(sitePaths); + Config update = new Config(); + update.setString("remote", remoteName1, "url", remoteUrl2); + + objectUnderTest.update(update); + + assertThat(objectUnderTest.getConfig().getString("remote", remoteName1, "url")) + .isEqualTo(remoteUrl2); + Config actual = loadRemoteConfig(remoteName1); + assertThat(actual.getString("remote", null, "url")).isEqualTo(remoteUrl2); + } + protected FileBasedConfig newRemoteConfig(String configFileName) { return new FileBasedConfig( sitePaths.etc_dir.resolve("replication/" + configFileName + ".config").toFile(), FS.DETECTED); } + + private Config loadRemoteConfig(String siteName) throws Exception { + FileBasedConfig config = newRemoteConfig(siteName); + config.load(); + return config; + } }
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/FileConfigResourceTest.java b/src/test/java/com/googlesource/gerrit/plugins/replication/FileConfigResourceTest.java new file mode 100644 index 0000000..92bf58d --- /dev/null +++ b/src/test/java/com/googlesource/gerrit/plugins/replication/FileConfigResourceTest.java
@@ -0,0 +1,121 @@ +// Copyright (C) 2024 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; + +import static com.google.common.io.RecursiveDeleteOption.ALLOW_INSECURE; +import static com.google.common.truth.Truth.assertThat; + +import com.google.common.io.MoreFiles; +import com.google.gerrit.server.config.SitePaths; +import com.googlesource.gerrit.plugins.replication.api.ConfigResource; +import java.nio.file.Files; +import java.nio.file.Path; +import org.eclipse.jgit.lib.Config; +import org.eclipse.jgit.storage.file.FileBasedConfig; +import org.eclipse.jgit.util.FS; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +public class FileConfigResourceTest { + private static final String VALUE_KEY = "value"; + private static final String INITIAL_KEY = "initial"; + private static final String UPDATABLE_KEY = "updatable"; + + private Path testDir; + private SitePaths sitePaths; + private ConfigResource configResource; + + @Before + public void setUp() throws Exception { + testDir = Files.createTempDirectory("fileConfigResourceTest"); + sitePaths = new SitePaths(testDir); + configResource = newFileConfigResource(); + } + + @After + public void tearDown() throws Exception { + MoreFiles.deleteRecursively(testDir, ALLOW_INSECURE); + } + + @Test + public void updateEmptyFile() throws Exception { + Config configUpdate = newConfigUpdate(); + + Config beforeUpdate = configResource.getConfig(); + assertThat(beforeUpdate.getSections()).isEmpty(); + + configResource.update(configUpdate); + Config updatedConfig = newFileConfigResource().getConfig(); + + assertConfigUpdate(updatedConfig); + } + + @Test + public void appendOptionToConfig() throws Exception { + FileBasedConfig rawConfig = getRawReplicationConfig(); + rawConfig.setInt(INITIAL_KEY, null, VALUE_KEY, 10); + rawConfig.save(); + configResource = newFileConfigResource(); + Config configUpdate = newConfigUpdate(); + + configResource.update(configUpdate); + Config updatedConfig = configResource.getConfig(); + + assertConfigUpdate(updatedConfig); + assertThat(updatedConfig.getInt(INITIAL_KEY, null, VALUE_KEY, -1)).isEqualTo(10); + } + + @Test + public void updateExistingOption() throws Exception { + int expectedValue = 20; + Config configUpdate = new Config(); + configUpdate.setInt(UPDATABLE_KEY, null, VALUE_KEY, expectedValue); + FileBasedConfig rawConfig = getRawReplicationConfig(newConfigUpdate()); + rawConfig.save(); + + configResource.update(configUpdate); + Config updatedConfig = configResource.getConfig(); + + assertConfigUpdate(updatedConfig, expectedValue); + } + + private FileConfigResource newFileConfigResource() { + return new FileConfigResource(sitePaths); + } + + private Config newConfigUpdate() { + Config configUpdate = new Config(); + configUpdate.setInt(UPDATABLE_KEY, null, VALUE_KEY, 1); + return configUpdate; + } + + private void assertConfigUpdate(Config config) { + assertConfigUpdate(config, 1); + } + + private void assertConfigUpdate(Config config, int expectedValue) { + assertThat(config.getInt(UPDATABLE_KEY, null, VALUE_KEY, -1)).isEqualTo(expectedValue); + } + + private FileBasedConfig getRawReplicationConfig() { + return getRawReplicationConfig(new Config()); + } + + private FileBasedConfig getRawReplicationConfig(Config base) { + Path configPath = sitePaths.etc_dir.resolve(FileConfigResource.CONFIG_NAME); + return new FileBasedConfig(base, configPath.toFile(), FS.DETECTED); + } +}
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/MergedConfigResourceTest.java b/src/test/java/com/googlesource/gerrit/plugins/replication/MergedConfigResourceTest.java index 18bb603..cae78e3 100644 --- a/src/test/java/com/googlesource/gerrit/plugins/replication/MergedConfigResourceTest.java +++ b/src/test/java/com/googlesource/gerrit/plugins/replication/MergedConfigResourceTest.java
@@ -22,6 +22,7 @@ import com.googlesource.gerrit.plugins.replication.api.ApiModule; import com.googlesource.gerrit.plugins.replication.api.ConfigResource; import com.googlesource.gerrit.plugins.replication.api.ReplicationConfigOverrides; +import org.apache.commons.lang3.NotImplementedException; import org.eclipse.jgit.lib.Config; import org.junit.Test; @@ -78,6 +79,11 @@ } @Override + public void update(Config config) { + throw new NotImplementedException("not implemented"); + } + + @Override public String getVersion() { return "base"; } @@ -93,6 +99,11 @@ } @Override + public void update(Config config) { + throw new NotImplementedException("not implemented"); + } + + @Override public String getVersion() { return "override"; }
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/ReplicationRemotesApiTest.java b/src/test/java/com/googlesource/gerrit/plugins/replication/ReplicationRemotesApiTest.java new file mode 100644 index 0000000..40d9846 --- /dev/null +++ b/src/test/java/com/googlesource/gerrit/plugins/replication/ReplicationRemotesApiTest.java
@@ -0,0 +1,212 @@ +// Copyright (C) 2024 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; + +import static com.google.common.io.RecursiveDeleteOption.ALLOW_INSECURE; +import static com.google.common.truth.Truth.assertThat; +import static com.google.gerrit.testing.GerritJUnit.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +import com.google.common.io.MoreFiles; +import com.google.common.truth.StringSubject; +import com.google.gerrit.extensions.registration.DynamicItem; +import com.google.gerrit.server.config.SitePaths; +import com.google.gerrit.server.securestore.SecureStore; +import com.google.inject.AbstractModule; +import com.google.inject.Guice; +import com.google.inject.Injector; +import com.googlesource.gerrit.plugins.replication.api.ConfigResource; +import com.googlesource.gerrit.plugins.replication.api.ReplicationConfigOverrides; +import com.googlesource.gerrit.plugins.replication.api.ReplicationRemotesApi; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; +import org.eclipse.jgit.lib.Config; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +public class ReplicationRemotesApiTest { + + private Path testSite; + private SecureStore secureStoreMock; + private FileConfigResource baseConfig; + private Injector testInjector; + private AtomicReference<ReplicationConfigOverrides> testOverrides; + private String url1; + private String remoteName1; + private String url2; + private String remoteName2; + + @Before + public void setUp() throws Exception { + testSite = Files.createTempDirectory("replicationRemotesUpdateTest"); + secureStoreMock = mock(SecureStore.class); + baseConfig = new FileConfigResource(new SitePaths(testSite)); + testOverrides = new AtomicReference<>(new TestReplicationConfigOverrides()); + testInjector = + Guice.createInjector( + new AbstractModule() { + @Override + protected void configure() { + bind(ConfigResource.class).toInstance(baseConfig); + bind(SecureStore.class).toInstance(secureStoreMock); + bind(ReplicationRemotesApi.class).to(ReplicationRemotesApiImpl.class); + DynamicItem.itemOf(binder(), ReplicationConfigOverrides.class); + DynamicItem.bind(binder(), ReplicationConfigOverrides.class) + .toProvider(testOverrides::get); + } + }); + url1 = "fake_url1"; + remoteName1 = "site1"; + url2 = "fake_url2"; + remoteName2 = "site2"; + } + + @After + public void tearDown() throws Exception { + MoreFiles.deleteRecursively(testSite, ALLOW_INSECURE); + } + + @Test + public void shouldThrowWhenNoRemotesInTheUpdate() { + Config update = new Config(); + ReplicationRemotesApi objectUnderTest = getReplicationRemotesApi(); + + assertThrows(IllegalArgumentException.class, () -> objectUnderTest.update(update)); + + update.setString("non-remote", null, "value", "one"); + + assertThrows(IllegalArgumentException.class, () -> objectUnderTest.update(update)); + } + + @Test + public void shouldReturnEmptyConfigWhenNoRemotes() { + ReplicationRemotesApi objectUnderTest = getReplicationRemotesApi(); + assertThat(objectUnderTest.get("site").getSections()).isEmpty(); + } + + @Test + public void addRemoteSectionsToBaseConfigWhenNoOverrides() throws Exception { + testOverrides.set(null); + + Config update = new Config(); + setRemoteSite(update, remoteName1, "url", url1); + setRemoteSite(update, remoteName2, "url", url2); + ReplicationRemotesApi objectUnderTest = getReplicationRemotesApi(); + + objectUnderTest.update(update); + + assertRemoteSite(baseConfig.getConfig(), remoteName1, "url").isEqualTo(url1); + assertRemoteSite(baseConfig.getConfig(), remoteName2, "url").isEqualTo(url2); + } + + @Test + public void shouldReturnRemotesFromBaseConfigWhenNoOverrides() { + ReplicationRemotesApi objectUnderTest = getReplicationRemotesApi(); + + baseConfig.getConfig().setString("remote", remoteName1, "url", url1); + baseConfig.getConfig().setString("remote", remoteName2, "url", url2); + + Config remotesConfig = objectUnderTest.get(remoteName1, remoteName2); + assertRemoteSite(remotesConfig, remoteName1, "url").isEqualTo(url1); + assertRemoteSite(remotesConfig, remoteName2, "url").isEqualTo(url2); + } + + @Test + public void addRemotesSectionToBaseOverridesConfig() throws Exception { + Config update = new Config(); + setRemoteSite(update, remoteName1, "url", url1); + setRemoteSite(update, remoteName2, "url", url2); + ReplicationRemotesApi objectUnderTest = getReplicationRemotesApi(); + + objectUnderTest.update(update); + + assertRemoteSite(testOverrides.get().getConfig(), remoteName1, "url").isEqualTo(url1); + assertRemoteSite(testOverrides.get().getConfig(), remoteName2, "url").isEqualTo(url2); + assertRemoteSite(baseConfig.getConfig(), remoteName1, "url").isNull(); + assertRemoteSite(baseConfig.getConfig(), remoteName2, "url").isNull(); + + Config remotesConfig = objectUnderTest.get(remoteName1, remoteName2); + assertRemoteSite(remotesConfig, remoteName1, "url").isEqualTo(url1); + assertRemoteSite(remotesConfig, remoteName2, "url").isEqualTo(url2); + } + + @Test + public void shouldSetPasswordViaSecureStoreButNotStoreInConfig() throws Exception { + Config update = new Config(); + String password = "encrypted-password"; + String remoteName = "site"; + setRemoteSite(update, remoteName, "password", password); + ReplicationRemotesApi objectUnderTest = getReplicationRemotesApi(); + + objectUnderTest.update(update); + + verify(secureStoreMock).setList("remote", remoteName, "password", List.of(password)); + assertRemoteSite(baseConfig.getConfig(), remoteName, "password").isNull(); + assertRemoteSite(testOverrides.get().getConfig(), remoteName, "password").isNull(); + assertRemoteSite(objectUnderTest.get(remoteName), remoteName, "password").isNull(); + } + + @Test + public void shouldSetUsernameViaSecureStoreButNotStoreInConfig() throws Exception { + Config update = new Config(); + String username = "encrypted-username"; + String remoteName = "site"; + setRemoteSite(update, remoteName, "username", username); + ReplicationRemotesApi objectUnderTest = getReplicationRemotesApi(); + + objectUnderTest.update(update); + + verify(secureStoreMock).setList("remote", remoteName, "username", List.of(username)); + assertRemoteSite(baseConfig.getConfig(), remoteName, "username").isNull(); + assertRemoteSite(testOverrides.get().getConfig(), remoteName, "username").isNull(); + assertRemoteSite(objectUnderTest.get(remoteName), remoteName, "username").isNull(); + } + + private void setRemoteSite(Config config, String remoteName, String name, String value) { + config.setString("remote", remoteName, name, value); + } + + private StringSubject assertRemoteSite(Config config, String remoteName, String name) { + return assertThat(config.getString("remote", remoteName, name)); + } + + private ReplicationRemotesApi getReplicationRemotesApi() { + return testInjector.getInstance(ReplicationRemotesApi.class); + } + + static class TestReplicationConfigOverrides implements ReplicationConfigOverrides { + private Config config = new Config(); + + @Override + public Config getConfig() { + return config; + } + + @Override + public void update(Config update) throws IOException { + config = update; + } + + @Override + public String getVersion() { + return "none"; + } + } +}