diff --git a/core/src/main/java/hudson/PluginManager.java b/core/src/main/java/hudson/PluginManager.java index c26e20d540..52e7e342a6 100644 --- a/core/src/main/java/hudson/PluginManager.java +++ b/core/src/main/java/hudson/PluginManager.java @@ -1,10 +1,13 @@ package hudson; import hudson.model.Hudson; +import hudson.model.UpdateCenter; +import hudson.model.AbstractModelObject; import hudson.util.Service; import java.util.Enumeration; import javax.servlet.ServletContext; +import javax.servlet.ServletException; import java.io.File; import java.io.FilenameFilter; import java.io.IOException; @@ -21,13 +24,15 @@ import java.util.logging.Level; import java.util.logging.Logger; import org.apache.commons.logging.LogFactory; +import org.kohsuke.stapler.StaplerRequest; +import org.kohsuke.stapler.StaplerResponse; /** * Manages {@link PluginWrapper}s. * * @author Kohsuke Kawaguchi */ -public final class PluginManager { +public final class PluginManager extends AbstractModelObject { /** * All discovered plugins. */ @@ -112,6 +117,14 @@ public final class PluginManager { return null; } + public String getDisplayName() { + return "Plugin Manager"; + } + + public String getSearchUrl() { + return "pluginManager"; + } + /** * Discover all the service provider implementations of the given class, * via META-INF/services. @@ -138,6 +151,26 @@ public final class PluginManager { LogFactory.release(uberClassLoader); } + /** + * Performs the installation of the plugins. + */ + public void doInstall(StaplerRequest req, StaplerResponse rsp) throws IOException, ServletException { + Enumeration en = req.getParameterNames(); + while (en.hasMoreElements()) { + String n = en.nextElement(); + if(n.startsWith("plugin.")) { + n = n.substring(7); + UpdateCenter.Plugin p = Hudson.getInstance().getUpdateCenter().getPlugin(n); + if(p==null) { + sendError("No such plugin: "+n,req,rsp); + return; + } + p.install(); + } + } + rsp.sendRedirect("../updateCenter/"); + } + private final class UberClassLoader extends ClassLoader { public UberClassLoader() { super(PluginManager.class.getClassLoader()); diff --git a/core/src/main/java/hudson/PluginWrapper.java b/core/src/main/java/hudson/PluginWrapper.java index f22292d0a0..b22af187d9 100644 --- a/core/src/main/java/hudson/PluginWrapper.java +++ b/core/src/main/java/hudson/PluginWrapper.java @@ -1,7 +1,9 @@ package hudson; import hudson.util.IOException2; +import hudson.util.VersionNumber; import hudson.model.Hudson; +import hudson.model.UpdateCenter; import org.apache.tools.ant.BuildException; import org.apache.tools.ant.Project; import org.apache.tools.ant.taskdefs.Expand; @@ -459,6 +461,36 @@ public final class PluginWrapper { return !disableFile.exists(); } + /** + * If the plugin has {@link #getUpdateInfo() an update}, + * returns the {@link UpdateCenter.Plugin} object. + */ + public UpdateCenter.Plugin getUpdateInfo() { + UpdateCenter uc = Hudson.getInstance().getUpdateCenter(); + UpdateCenter.Plugin p = uc.getPlugin(getShortName()); + if(p==null) return null; + + try { + if(new VersionNumber(getVersion()).compareTo(new VersionNumber(p.version)) < 0) + return p; + return null; + } catch (IllegalArgumentException e) { + // couldn't parse it as the version number. ignore. + return null; + } + } + + /** + * Returns true if this plugin has update in the update center. + * + *

+ * This method is conservative in the sense that if the version number is incomprehensible, + * it always returns false. + */ + public boolean hasUpdate() { + return getUpdateInfo()!=null; + } + /** * Explodes the plugin into a directory, if necessary. */ diff --git a/core/src/main/java/hudson/model/UpdateCenter.java b/core/src/main/java/hudson/model/UpdateCenter.java index 7270344fba..c12820c347 100644 --- a/core/src/main/java/hudson/model/UpdateCenter.java +++ b/core/src/main/java/hudson/model/UpdateCenter.java @@ -1,21 +1,42 @@ package hudson.model; +import hudson.PluginManager; +import hudson.PluginWrapper; +import hudson.util.DaemonThreadFactory; import hudson.util.TextFile; import static hudson.util.TimeUnit2.DAYS; import net.sf.json.JSONObject; -import org.kohsuke.stapler.StaplerRequest; +import org.apache.commons.io.input.CountingInputStream; import org.kohsuke.stapler.DataBoundConstructor; +import org.kohsuke.stapler.StaplerRequest; import java.io.File; +import java.io.FileOutputStream; import java.io.IOException; -import java.util.logging.Logger; +import java.io.OutputStream; +import java.net.URL; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; import java.util.Map; -import java.util.HashMap; import java.util.Set; +import java.util.TreeMap; +import java.util.Vector; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.logging.Level; +import java.util.logging.Logger; /** * Controls update center capability. * + *

+ * The main job of this class is to keep track of the latest update center metadata file, and perform installations. + * Much of the UI about choosing plugins to install is done in {@link PluginManager}. + * * @author Kohsuke Kawaguchi * @since 1.220 */ @@ -32,6 +53,31 @@ public class UpdateCenter { */ private volatile long lastAttempt = -1; + /** + * {@link ExecutorService} that performs installation. + */ + private final ThreadPoolExecutor installerService = new ThreadPoolExecutor(1, 1, + 0L, TimeUnit.MILLISECONDS, + new LinkedBlockingQueue(), + new DaemonThreadFactory(new ThreadFactory() { + public Thread newThread(Runnable r) { + Thread t = new Thread(r); + t.setName("Update center installer thread"); + return t; + } + })) { + @Override + protected void finalize() { + super.finalize(); + shutdown(); + } + }; + + /** + * List of completed {@link InstallationStatus}es. Access needs to be synchronized. + */ + private final Vector installationStatuses = new Vector(); + /** * Returns true if it's time for us to check for new version. */ @@ -65,15 +111,36 @@ public class UpdateCenter { * * @return null if no data is available. */ - public Data getData() throws IOException { + public Data getData() { TextFile df = getDataFile(); if(df.exists()) { - return new Data(JSONObject.fromObject(df.read())); + try { + return new Data(JSONObject.fromObject(df.read())); + } catch (IOException e) { + LOGGER.log(Level.SEVERE,"Failed to parse "+df,e); + df.delete(); // if we keep this file, it will cause repeated failures + return null; + } } else { return null; } } + /** + * Gets the information about a specific plugin. + * + * @param artifactId + * The short name of the plugin. Corresponds to {@link PluginWrapper#getShortName()}. + * + * @return + * null if no such information is found. + */ + public Plugin getPlugin(String artifactId) { + Data dt = getData(); + if(dt==null) return null; + return dt.plugins.get(artifactId); + } + /** * This is where we store the update center data. */ @@ -81,10 +148,29 @@ public class UpdateCenter { return new TextFile(new File(Hudson.getInstance().root,"update-center.json")); } + /** + * Returns the list of plugins that are updates to currently installed ones. + * + * @return + * can be empty but never null. + */ + public List getUpdates() { + Data data = getData(); + if(data==null) return Collections.emptyList(); // fail to determine + + List r = new ArrayList(); + for (PluginWrapper pw : Hudson.getInstance().getPluginManager().getPlugins()) { + Plugin p = pw.getUpdateInfo(); + if(p!=null) r.add(p); + } + + return r; + } + /** * In-memory representation of the update center data. */ - public static final class Data { + public final class Data { /** * The latest hudson.war. */ @@ -92,7 +178,7 @@ public class UpdateCenter { /** * Plugins in the official repository, keyed by their artifact IDs. */ - public final Map plugins = new HashMap(); + public final Map plugins = new TreeMap(String.CASE_INSENSITIVE_ORDER); Data(JSONObject o) { core = new Entry(o.getJSONObject("core")); @@ -123,13 +209,17 @@ public class UpdateCenter { } } - public static final class Plugin extends Entry { + public final class Plugin extends Entry { /** * Optional URL to the Wiki page that discusses this plugin. */ public final String wiki; /** * Human readable title of the plugin, taken from Wiki page. + * Can be null. + * + *

+ * beware of XSS vulnerability since this data comes from Wiki */ public final String title; @@ -146,6 +236,108 @@ public class UpdateCenter { else return null; } + + public String getDisplayName() { + if(title!=null) return title; + return name; + } + + /** + * If some version of this plugin is currently installed, return {@link PluginWrapper}. + * Otherwise null. + */ + public PluginWrapper getInstalled() { + PluginManager pm = Hudson.getInstance().getPluginManager(); + return pm.getPlugin(name); + } + + /** + * Schedules the installation of this plugin. + * + *

+ * This is mainly intended to be called from the UI. The actual installation work happens + * asynchronously in another thread. + */ + public void install() { + Hudson.getInstance().checkPermission(Hudson.ADMINISTER); + + LOGGER.info("Scheduling the installation of "+getDisplayName()); + installerService.submit(new Runnable() { + public void run() { + try { + // for security reasons, only install from hudson.dev.java.net for now, which is also conveniently + // https to guarantee transport level security. + if(!url.startsWith("https://hudson.dev.java.net/")) { + throw new IOException("Installation from non-official repository at "+url+" is not support yet"); + } + + // In the future if we are to open up update center to 3rd party, we need more elaborate scheme + // like signing to ensure the safety of the bits. + CountingInputStream in = new CountingInputStream(new URL(url).openStream()); + byte[] buf = new byte[8192]; + int len; + + File baseDir = Hudson.getInstance().getPluginManager().rootDir; + File target = new File(baseDir, name + ".tmp"); + OutputStream out = new FileOutputStream(target); + + while((len=in.read(buf))>=0) { + out.write(buf,0,len); + } + + in.close(); + out.close(); + + File hpi = new File(baseDir, name + ".hpi"); + hpi.delete(); + if(!target.renameTo(hpi)) { + throw new IOException("Failed to rename "+target+" to "+hpi); + } + + installationStatuses.add(new Success(Plugin.this)); + + } catch (IOException e) { + LOGGER.log(Level.SEVERE, "Failed to install "+name,e); + installationStatuses.add(new Failure(Plugin.this,e)); + } + } + }); + } + } + + /** + * Indicates the status or the result of a plugin installation. + */ + public abstract class InstallationStatus { + /** + * Plugin that is installing, installed, or attempted to be installed. + */ + public final Plugin plugin; + + protected InstallationStatus(Plugin plugin) { + this.plugin = plugin; + } + } + + /** + * Indicates that the installation of a plugin failed. + */ + public class Failure extends InstallationStatus { + public final Throwable problem; + + public Failure(Plugin plugin, Throwable problem) { + super(plugin); + this.problem = problem; + } + } + + /** + * Indicates that the plugin was successfully installed. + */ + public class Success extends InstallationStatus { + public Success(Plugin plugin) { + super(plugin); + } } private static final long DAY = DAYS.toMillis(1); diff --git a/core/src/main/resources/hudson/PluginManager/NO-TRANSLATION-PLEASE-FOR-NOW b/core/src/main/resources/hudson/PluginManager/NO-TRANSLATION-PLEASE-FOR-NOW new file mode 100644 index 0000000000..0975831c05 --- /dev/null +++ b/core/src/main/resources/hudson/PluginManager/NO-TRANSLATION-PLEASE-FOR-NOW @@ -0,0 +1,2 @@ +This is still a work in progress, so don't waste your time translating this. Things may change. +Please give this a bit more time for it to stablize. \ No newline at end of file diff --git a/core/src/main/resources/hudson/PluginManager/available.jelly b/core/src/main/resources/hudson/PluginManager/available.jelly new file mode 100644 index 0000000000..b8cb32dfa4 --- /dev/null +++ b/core/src/main/resources/hudson/PluginManager/available.jelly @@ -0,0 +1,20 @@ + + + + + + + +

+ + + + + + + + + + diff --git a/core/src/main/resources/hudson/PluginManager/index.jelly b/core/src/main/resources/hudson/PluginManager/index.jelly new file mode 100644 index 0000000000..3cf106134b --- /dev/null +++ b/core/src/main/resources/hudson/PluginManager/index.jelly @@ -0,0 +1,25 @@ + + + + + + + +
+ + + + + + + +
+
+
diff --git a/core/src/main/resources/hudson/PluginManager/sidepanel.jelly b/core/src/main/resources/hudson/PluginManager/sidepanel.jelly new file mode 100644 index 0000000000..3b1597f7c9 --- /dev/null +++ b/core/src/main/resources/hudson/PluginManager/sidepanel.jelly @@ -0,0 +1,11 @@ + + + + + + + + + \ No newline at end of file diff --git a/core/src/main/resources/hudson/PluginManager/table.jelly b/core/src/main/resources/hudson/PluginManager/table.jelly new file mode 100644 index 0000000000..768b57a091 --- /dev/null +++ b/core/src/main/resources/hudson/PluginManager/table.jelly @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + +
InstallNameVersion
+ +
+
+ No updates +
+
+ +
+ +
+
+
diff --git a/core/src/test/java/hudson/model/UpdateCenterTest.java b/core/src/test/java/hudson/model/UpdateCenterTest.java index b44c3afa49..ebe7665ca6 100644 --- a/core/src/test/java/hudson/model/UpdateCenterTest.java +++ b/core/src/test/java/hudson/model/UpdateCenterTest.java @@ -18,7 +18,8 @@ public class UpdateCenterTest extends TestCase { String jsonp = IOUtils.toString(url.openStream()); String json = jsonp.substring(jsonp.indexOf('(')+1,jsonp.lastIndexOf(')')); - UpdateCenter.Data data = new UpdateCenter.Data(JSONObject.fromObject(json)); + UpdateCenter uc = new UpdateCenter(); + UpdateCenter.Data data = uc.new Data(JSONObject.fromObject(json)); assertTrue(data.core.url.startsWith("https://hudson.dev.java.net/")); assertTrue(data.plugins.containsKey("rake")); System.out.println(data.core.url);