package com.atlassian.labs.speakeasy.git; import com.atlassian.event.api.EventListener; import com.atlassian.event.api.EventPublisher; import com.atlassian.labs.speakeasy.event.*; import com.atlassian.labs.speakeasy.manager.PluginOperationFailedException; import com.atlassian.labs.speakeasy.manager.ZipWriter; import com.atlassian.labs.speakeasy.util.BundleUtil; import com.atlassian.labs.speakeasy.util.ExtensionValidate; import com.atlassian.labs.speakeasy.util.exec.KeyedSyncExecutor; import com.atlassian.labs.speakeasy.util.exec.Operation; import com.atlassian.sal.api.ApplicationProperties; import com.google.common.collect.MapMaker; import org.apache.commons.io.FileUtils; import org.apache.commons.io.IOUtils; import org.eclipse.jgit.api.Git; import org.eclipse.jgit.api.ResetCommand; import org.eclipse.jgit.api.Status; import org.eclipse.jgit.api.errors.ConcurrentRefUpdateException; import org.eclipse.jgit.api.errors.NoFilepatternException; import org.eclipse.jgit.api.errors.NoHeadException; import org.eclipse.jgit.api.errors.NoMessageException; import org.eclipse.jgit.api.errors.WrongRepositoryStateException; import org.eclipse.jgit.lib.Repository; import org.eclipse.jgit.lib.StoredConfig; import org.eclipse.jgit.storage.file.FileRepositoryBuilder; import org.osgi.framework.Bundle; import org.osgi.framework.BundleContext; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.DisposableBean; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import java.io.File; import java.io.FileOutputStream; import java.io.IOException; import java.util.Map; import java.util.Set; import static com.atlassian.labs.speakeasy.util.BundleUtil.findBundleForPlugin; import static com.atlassian.labs.speakeasy.util.BundleUtil.getPublicBundlePathsRecursive; import static com.atlassian.labs.speakeasy.util.ExtensionValidate.isValidExtensionKey; import static com.google.common.collect.Sets.newHashSet; /** * */ @Component public class DefaultGitRepositoryManager implements DisposableBean, GitRepositoryManager { private static final String BUNDLELASTMODIFIED = "bundlelastmodified"; private final File repositoriesDir; private final Map<String,Repository> repositories = new MapMaker().makeMap(); private final BundleContext bundleContext; private final EventPublisher eventPublisher; private final KeyedSyncExecutor<Repository,AbstractExtensionEvent<?>> executor; private static final Logger log = LoggerFactory.getLogger(DefaultGitRepositoryManager.class); private static final AbstractExtensionEvent SYNC_EVENT = new AbstractExtensionEvent("") { @Override public String getMessage() { return "Auto-sync from deployed extension"; } @Override public String getUserEmail() { return "speakeasy@atlassian.com"; } @Override public String getUserName() { return "speakeasy"; } }; @Autowired public DefaultGitRepositoryManager(BundleContext bundleContext, ApplicationProperties applicationProperties, EventPublisher eventPublisher) { this.bundleContext = bundleContext; this.eventPublisher = eventPublisher; repositoriesDir = new File(applicationProperties.getHomeDirectory(), "data/speakeasy/repositories"); repositoriesDir.mkdirs(); executor = new KeyedSyncExecutor<Repository, AbstractExtensionEvent<?>>() { @Override protected Repository getTarget(String id, AbstractExtensionEvent<?> targetContext) throws Exception { return getRepository(id, targetContext); } @Override protected boolean allowKey(String id) { return isValidExtensionKey(id); } }; eventPublisher.register(this); } private Repository getRepository(String id, AbstractExtensionEvent event) throws NoHeadException, NoMessageException, ConcurrentRefUpdateException, WrongRepositoryStateException, IOException, NoFilepatternException { Repository repo = repositories.get(id); if (repo == null) { final File repoDir = new File(repositoriesDir, id); FileRepositoryBuilder builder = new FileRepositoryBuilder(); repo = builder.setWorkTree(repoDir). setGitDir(new File(repoDir, ".git")). setMustExist(false). build(); Bundle bundle = findBundleForPlugin(bundleContext, id); if (bundle != null) { if (!repo.getObjectDatabase().exists()) { repo.create(); updateRepositoryIfDirty(repo, event, bundle); } else { long modified = repo.getConfig().getLong("speakeasy", null, BUNDLELASTMODIFIED, 0); if (modified != bundle.getLastModified()) { updateRepositoryIfDirty(repo, event, bundle); } } } else if (ExtensionValidate.isValidExtensionKey(id) && !repo.getDirectory().exists()) { repo.create(); } repositories.put(id, repo); } return repo; } public File getRepositoriesDir() { return this.repositoriesDir; } private void updateRepositoryIfDirty(Repository repo, AbstractExtensionEvent event, Bundle bundle) throws IOException, NoFilepatternException, NoHeadException, NoMessageException, ConcurrentRefUpdateException, WrongRepositoryStateException { final File workTree = repo.getWorkTree(); Git git = new Git(repo); Set<File> workTreeFilesToDelete = findFiles(repo.getWorkTree()); for (String path : getPublicBundlePathsRecursive(bundle, "")) { File target = new File(workTree, path); workTreeFilesToDelete.remove(target); if (path.endsWith("/")) { target.mkdirs(); } else { FileOutputStream fout = null; try { fout = new FileOutputStream(target); IOUtils.copy(bundle.getResource(path).openStream(), fout); fout.close(); } finally { IOUtils.closeQuietly(fout); } git.add().addFilepattern(path).call(); } } for (File file : workTreeFilesToDelete) { FileUtils.deleteQuietly(file); } Status status = git.status().call(); if (!status.getAdded().isEmpty() || !status.getChanged().isEmpty() || !status.getMissing().isEmpty() || !status.getRemoved().isEmpty() || !status.getUntracked().isEmpty()) { git.commit(). setAll(true). setAuthor(event.getUserName(), event.getUserEmail()). setCommitter("speakeasy", "speakeasy@atlassian.com"). setMessage(event.getMessage()). call(); log.info("Git repository {} updated", repo.getWorkTree().getName()); } updateWithBundleTimestamp(repo, bundle); } private void updateWithBundleTimestamp(Repository repo, Bundle bundle) throws IOException { StoredConfig config = repo.getConfig(); config.setLong("speakeasy", null, BUNDLELASTMODIFIED, bundle.getLastModified()); config.save(); } private Set<File> findFiles(File workTree) { Set<File> paths = newHashSet(); for (File child : workTree.listFiles()) { if (!".git".equals(child.getName())) { paths.add(child); if (child.isDirectory()) { paths.addAll(findFiles(child)); } } } return paths; } public void ensureRepository(String name) { executor.forKey(name, SYNC_EVENT, new Operation<Repository, Void>() { public Void operateOn(Repository repo) throws Exception { return null; } }); } public <R> R operateOnRepository(String name, Operation<Repository, R> operation) { return executor.forKey(name, SYNC_EVENT, operation); } public File buildJarFromRepository(String pluginKey) { File jar = executor.forKey(pluginKey, SYNC_EVENT, new Operation<Repository, File>() { public File operateOn(Repository repo) throws Exception { Git git = new Git(repo); if (repo.getAllRefs().containsKey("refs/heads/master")) { git.reset().setMode(ResetCommand.ResetType.HARD).setRef("HEAD").call(); for (String path : git.status().call().getUntracked()) { new File(repo.getWorkTree(), path).delete(); } } return ZipWriter.addDirectoryContentsToJar(repo.getWorkTree(), ".git"); } }); if (jar == null) { throw new PluginOperationFailedException("Invalid plugin key: " + pluginKey, pluginKey); } return jar; } @EventListener public void onPluginInstalledEvent(final ExtensionInstalledEvent event) { executor.forKey(event.getPluginKey(), event, new Operation<Repository, Void>() { public Void operateOn(Repository repo) throws Exception { // just getting the repo for the first time will create it updateRepositoryIfDirty(repo, event, BundleUtil.findBundleForPlugin(bundleContext, repo.getWorkTree().getName())); return null; } }); } @EventListener public void onPluginUninstalledEvent(final ExtensionUninstalledEvent event) { executor.forKey(event.getPluginKey(), event, new Operation<Repository, Void>() { public Void operateOn(Repository repo) throws Exception { removeRepository(repo); return null; } }); } @EventListener public void onPluginUpdatedEvent(final ExtensionUpdatedEvent event) { executor.forKey(event.getPluginKey(), event, new Operation<Repository, Void>() { public Void operateOn(Repository repo) throws Exception { updateRepositoryIfDirty(repo, event, BundleUtil.findBundleForPlugin(bundleContext, event.getPluginKey())); return null; } }); } @EventListener public void onPluginForkedEvent(final ExtensionForkedEvent event) { executor.forKey(event.getPluginKey(), event, new Operation<Repository, Void>() { public Void operateOn(Repository repo) throws Exception { cloneRepository(repo, event.getForkedPluginKey()); return null; } }); } private void cloneRepository(Repository repo, String forkedPluginKey) { Git git = new Git(repo); git.cloneRepository() .setURI(repo.getDirectory().toURI().toString()) .setDirectory(new File(repositoriesDir, forkedPluginKey)) .setBare(false) .call(); executor.forKey(forkedPluginKey, SYNC_EVENT, new Operation<Repository, Void>() { public Void operateOn(Repository repo) throws Exception { // do nothing, we just want to force a sync return null; } }); log.info("Git repository {} cloned to {}", repo.getWorkTree().getName(), forkedPluginKey); } private void removeRepository(Repository repo) throws IOException { File workTreeDir = repo.getWorkTree(); repo.close(); FileUtils.deleteDirectory(workTreeDir); final String pluginKey = workTreeDir.getName(); repositories.remove(pluginKey); log.info("Git repository {} removed", pluginKey); } public void destroy() throws Exception { eventPublisher.unregister(this); } }