package nl.minicom.gitolite.manager.models; import java.io.File; import java.io.FileFilter; import java.io.FileReader; import java.io.FileWriter; import java.io.IOException; import java.util.Collection; import java.util.List; import java.util.Queue; import java.util.Set; import java.util.concurrent.Callable; import java.util.concurrent.ExecutionException; import java.util.concurrent.ScheduledThreadPoolExecutor; import java.util.concurrent.atomic.AtomicReference; import nl.minicom.gitolite.manager.exceptions.GitException; import nl.minicom.gitolite.manager.exceptions.ModificationException; import nl.minicom.gitolite.manager.exceptions.ServiceUnavailable; import nl.minicom.gitolite.manager.git.GitManager; import nl.minicom.gitolite.manager.git.JGitManager; import nl.minicom.gitolite.manager.models.Recorder.Modification; import org.eclipse.jgit.transport.CredentialsProvider; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import com.google.common.base.Preconditions; import com.google.common.collect.ImmutableList; import com.google.common.collect.Lists; import com.google.common.collect.Queues; import com.google.common.collect.Sets; import com.google.common.io.Files; import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.SettableFuture; /** * The {@link ConfigManager} class is designed to be used by developers who wish * to manage their gitolite configuration. * * @author Michael de Jong <<a href="mailto:michaelj@minicom.nl">michaelj@minicom.nl</a>> */ public class ConfigManager { private static final Logger log = LoggerFactory.getLogger(ConfigManager.class); private static final String KEY_DIRECTORY_NAME = "keydir"; private static final String CONF_FILE_NAME = "gitolite.conf"; private static final String CONF_DIRECTORY_NAME = "conf"; /** * Constructs a {@link ConfigManager} which is based on the provided URI. * * @param gitUri The URI of the remote configuration repository. * * @return A {@link ConfigManager} which allows a developer to manipulate the * configuration repository. */ public static ConfigManager create(String gitUri) { return create(gitUri, null); } /** * Constructs a {@link ConfigManager} which is based on the provided URI and * {@link CredentialsProvider}. * * @param gitUri The URI of the remote configuration repository. * * @param credentialProvider The {@link CredentialsProvider} which handles * the authentication of the git user who accesses the remote * repository containing the configuration. * * @return A {@link ConfigManager} which allows a developer to manipulate the * configuration repository. */ public static ConfigManager create(String gitUri, CredentialsProvider credentialProvider) { return create(gitUri, Files.createTempDir(), credentialProvider); } /** * Constructs a {@link ConfigManager} which is based on the provided URI, a * working directory and {@link CredentialsProvider}. * * @param gitUri The URI of the remote configuration repository. * * @param workingDirectory The directory where the configuration repository * needs to be cloned to. * * @param credentialProvider The {@link CredentialsProvider} which handles * the authentication of the git user who accesses the remote * repository containing the configuration. * * @return A {@link ConfigManager} which allows a developer to manipulate the * configuration repository. */ public static ConfigManager create(String gitUri, File workingDirectory, CredentialsProvider credentialProvider) { return new ConfigManager(gitUri, new JGitManager(workingDirectory, credentialProvider)); } private final String gitUri; private final GitManager git; private final File workingDirectory; private final Worker worker; private final AtomicReference<Config> config; private final Object diskLock = new Object(); /** * Constructs a new {@link ConfigManager} object. * * @param gitUri The URI to clone from and push changes to. * * @param gitManager The {@link GitManager} which will handle the git * operations. */ ConfigManager(String gitUri, GitManager gitManager) { Preconditions.checkNotNull(gitUri); Preconditions.checkNotNull(gitManager); this.gitUri = gitUri; this.git = gitManager; this.workingDirectory = git.getWorkingDirectory(); this.config = new AtomicReference<>(); this.worker = new Worker(); } private void ensureAdminRepoIsUpToDate() throws ServiceUnavailable, GitException { if (!new File(workingDirectory, ".git").exists()) { log.info("Cloning from: {} to: {}", gitUri, workingDirectory); git.clone(gitUri); } else { log.info("Pulling from: {}", gitUri); git.pull(); } } private void ensureAdminRepoPresent() throws IOException, ServiceUnavailable, GitException { if (!new File(workingDirectory, ".git").exists()) { log.info("Cloning from: {} to: {}", gitUri, workingDirectory); git.clone(gitUri); readConfig(); } } /** * This method returns a representation of the current gitolite configuration. * * @return A {@link Config} object, representing the gitolite configuration. * * @throws IOException If one or more files in the repository could not be read. * * @throws ServiceUnavailable If the service could not be reached. * * @throws GitException If an exception occurred while using the Git API. */ public Config get() throws IOException, ServiceUnavailable, GitException { ensureAdminRepoPresent(); Config copy = config.get().copy(); copy.getRecorder().record(); return copy; } /** * This method applies any changes that were made to the specified {@link Config} * object to the gitolite server. This method returns a {@link ListenableFuture} * which can be used to get a notification when the changes have been applied, or * if the changes could not be applied due to conflicts. * * @param config * The {@link Config} object to apply the changes of to the gitolite server. * * @return * A {@link ListenableFuture} which notifies the owner of completion or failure. */ public ListenableFuture<Void> applyAsync(Config config) { List<Modification> recording = config.getRecorder().stop(); return worker.submit(recording); } /** * This method applies any changes that were made to the specified {@link Config} * object to the gitolite server. This method blocks until the operation has completed * or failed. * * @param config * The {@link Config} object to apply the changes of to the gitolite server. * * @throws ModificationException * When the changes conflict to other changes, and thus could not be applied. */ public void apply(Config config) throws ModificationException { ListenableFuture<Void> future = applyAsync(config); try { future.get(); } catch (InterruptedException e) { log.error(e.getMessage(), e); throw new RuntimeException(e); } catch (ExecutionException e) { try { throw e.getCause(); } catch (ModificationException e1) { throw e1; } catch (Throwable e1) { log.error(e.getMessage(), e); throw new RuntimeException(e1); } } } private void writeAndPush() throws IOException, ServiceUnavailable, GitException { Config newConfig = config.get(); if (newConfig == null) { throw new IllegalStateException("Config has not yet been loaded!"); } synchronized (diskLock) { log.info("Writing Config object to disk"); ConfigWriter.write(newConfig, new FileWriter(getConfigFile())); Set<File> writtenKeys = KeyWriter.writeKeys(newConfig, ensureKeyDirectory()); Set<File> orphanedKeyFiles = listKeys(); orphanedKeyFiles.removeAll(writtenKeys); for (File orphanedKeyFile : orphanedKeyFiles) { git.remove("keydir/" + orphanedKeyFile.getName()); } } git.commitChanges(); git.push(); } private Set<File> listKeys() { Set<File> keys = Sets.newHashSet(); synchronized (diskLock) { File keyDir = new File(workingDirectory, "keydir"); if (keyDir.exists()) { File[] keyFiles = keyDir.listFiles(new FileFilter() { @Override public boolean accept(File file) { return file.getName().endsWith(".pub"); } }); for (File keyFile : keyFiles) { keys.add(keyFile); } } } return keys; } private Config readConfig() throws IOException { synchronized (diskLock) { Config read = ConfigReader.read(new FileReader(getConfigFile())); KeyReader.readKeys(read, ensureKeyDirectory()); config.set(read); return read; } } private File getConfigFile() { synchronized (diskLock) { File confDirectory = new File(workingDirectory, CONF_DIRECTORY_NAME); if (!confDirectory.exists()) { throw new IllegalStateException("Could not open " + CONF_DIRECTORY_NAME + "/ directory!"); } File confFile = new File(confDirectory, CONF_FILE_NAME); return confFile; } } private File ensureKeyDirectory() { synchronized (diskLock) { File keyDir = new File(workingDirectory, KEY_DIRECTORY_NAME); keyDir.mkdir(); return keyDir; } } /** * The {@link Worker} class is a simple class which is notified of any incoming {@link Modification}s * and processes them in batches of at most 10. */ private class Worker { protected static final int MAXIMUM_BATCH_SIZE = 10; private final ScheduledThreadPoolExecutor executor; private final Queue<UnitOfWork> modifications; public Worker() { this.modifications = Queues.newConcurrentLinkedQueue(); this.executor = new ScheduledThreadPoolExecutor(1); startWorker(); } private void startWorker() { executor.submit(new Callable<Void>() { @Override public Void call() throws Exception { try { waitUntilModificationsArePresent(); log.debug("Worker found changes"); Collection<SettableFuture<Void>> succeeded = applyChanges(false); try { log.info("Worker is pushing changes to remote repository"); writeAndPush(); } catch (IOException | ServiceUnavailable | IllegalStateException e) { log.error("Worker failed to push changes to remote repository, notifying owners", e); for (SettableFuture<Void> future : succeeded) { future.setException(e); } return null; } log.debug("Worker is notifying changeset owners"); for (SettableFuture<Void> future : succeeded) { future.set(null); } } finally { startWorker(); } return null; } }); } private void waitUntilModificationsArePresent() throws InterruptedException { synchronized (modifications) { while (modifications.isEmpty()) { log.debug("Worker is waiting for changes..."); modifications.wait(); } } } private Collection<SettableFuture<Void>> applyChanges(boolean update) throws ServiceUnavailable, IOException, GitException { if (update) { log.info("Pulling changes from remote repository"); ensureAdminRepoIsUpToDate(); } Collection<SettableFuture<Void>> succeeded = Lists.newArrayList(); Config current = config.get().copy(); log.info("Worker is applying {} changeset(s)", modifications.size()); while (!modifications.isEmpty() && succeeded.size() < MAXIMUM_BATCH_SIZE) { Config fallback = current.copy(); UnitOfWork unit = modifications.poll(); try { log.info("Worker is applying {} change(s)", unit.getModifications().size()); for (Modification change : unit.getModifications()) { change.apply(current); } succeeded.add(unit.getFuture()); } catch (ModificationException e) { log.error("Worker failed to apply a changeset, notifying owner"); unit.getFuture().setException(e); current = fallback; } } log.info("Worker successfully applied {} changeset", succeeded.size()); config.set(current); return succeeded; } public ListenableFuture<Void> submit(List<Modification> recording) { if (recording == null || recording.isEmpty()) { SettableFuture<Void> future = SettableFuture.create(); future.set(null); return future; } log.info("Submitting a new changeset, containing {} changes", recording.size()); UnitOfWork unit = new UnitOfWork(recording); synchronized (modifications) { modifications.offer(unit); modifications.notify(); } return unit.getFuture(); } } /** * The {@link UnitOfWork} class is a data object, which holds a reference to the * {@link ImmutableList} of {@link Modification}s which need to be applied, and * an internally created {@link SettableFuture} object, to which others can attach * listeners. */ private static class UnitOfWork { private final ImmutableList<Modification> modifications; private final SettableFuture<Void> future; public UnitOfWork(List<Modification> modifications) { this.modifications = ImmutableList.copyOf(modifications); this.future = SettableFuture.create(); } public ImmutableList<Modification> getModifications() { return modifications; } public SettableFuture<Void> getFuture() { return future; } } }