/* * The MIT License * * Copyright (c) 2004-2010, Sun Microsystems, Inc., Kohsuke Kawaguchi, Yahoo! Inc., Seiji Sogabe * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal * in the Software without restriction, including without limitation the rights * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the Software is * furnished to do so, subject to the following conditions: * * The above copyright notice and this permission notice shall be included in * all copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN * THE SOFTWARE. */ package hudson.model; import hudson.BulkChange; import hudson.Extension; import hudson.ExtensionPoint; import hudson.Functions; import hudson.PluginManager; import hudson.PluginWrapper; import hudson.ProxyConfiguration; import hudson.Util; import hudson.XmlFile; import static hudson.init.InitMilestone.PLUGINS_STARTED; import hudson.init.Initializer; import hudson.lifecycle.Lifecycle; import hudson.lifecycle.RestartNotSupportedException; import hudson.model.UpdateCenter.DownloadJob; import hudson.model.UpdateSite.Data; import hudson.model.UpdateSite.Plugin; import hudson.model.listeners.SaveableListener; import hudson.security.ACL; import hudson.util.DaemonThreadFactory; import hudson.util.HttpResponses; import hudson.util.IOException2; import hudson.util.PersistedList; import hudson.util.XStream2; import jenkins.RestartRequiredException; import jenkins.model.Jenkins; import org.acegisecurity.Authentication; import org.apache.commons.io.input.CountingInputStream; import org.apache.commons.io.output.NullOutputStream; import org.jvnet.localizer.Localizable; import org.kohsuke.stapler.HttpResponse; import org.kohsuke.stapler.StaplerRequest; import org.kohsuke.stapler.StaplerResponse; import javax.net.ssl.SSLHandshakeException; import javax.servlet.ServletException; import java.io.File; import java.io.FileOutputStream; import java.io.IOException; import java.io.OutputStream; import java.net.MalformedURLException; import java.net.URL; import java.net.URLConnection; import java.net.UnknownHostException; import java.util.ArrayList; import java.util.Collections; import java.util.HashSet; import java.util.List; import java.util.Set; import java.util.TreeSet; import java.util.Vector; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future; import java.util.concurrent.ThreadFactory; import java.util.concurrent.atomic.AtomicInteger; import java.util.jar.Attributes; import java.util.jar.JarFile; import java.util.logging.Level; import java.util.logging.Logger; import org.acegisecurity.context.SecurityContextHolder; /** * 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}. *
* The update center can be configured to contact alternate servers for updates
* and plugins, and to use alternate strategies for downloading, installing
* and updating components. See the Javadocs for {@link UpdateCenterConfiguration}
* for more information.
*
* @author Kohsuke Kawaguchi
* @since 1.220
*/
public class UpdateCenter extends AbstractModelObject implements Saveable {
private static final String UPDATE_CENTER_URL = System.getProperty(UpdateCenter.class.getName()+".updateCenterUrl","http://updates.jenkins-ci.org/");
/**
* {@link ExecutorService} that performs installation.
*/
private final ExecutorService installerService = Executors.newSingleThreadExecutor(
new DaemonThreadFactory(new ThreadFactory() {
public Thread newThread(Runnable r) {
Thread t = new Thread(r);
t.setName("Update center installer thread");
return t;
}
}));
/**
* List of created {@link UpdateCenterJob}s. Access needs to be synchronized.
*/
private final Vector
* Until 1.333, this extension point used to control the configuration of
* where to get updates (hence the name of this class), but with the introduction
* of multiple update center sites capability, that functionality is achieved by
* simply installing another {@link UpdateSite}.
*
*
* See {@link UpdateSite} for how to manipulate them programmatically.
*
* @since 1.266
*/
@SuppressWarnings({"UnusedDeclaration"})
public static class UpdateCenterConfiguration implements ExtensionPoint {
/**
* Creates default update center configuration - uses settings for global update center.
*/
public UpdateCenterConfiguration() {
}
/**
* Check network connectivity by trying to establish a connection to
* the host in connectionCheckUrl.
*
* @param job The connection checker that is invoking this strategy.
* @param connectionCheckUrl A string containing the URL of a domain
* that is assumed to be always available.
* @throws IOException if a connection can't be established
*/
public void checkConnection(ConnectionCheckJob job, String connectionCheckUrl) throws IOException {
testConnection(new URL(connectionCheckUrl));
}
/**
* Check connection to update center server.
*
* @param job The connection checker that is invoking this strategy.
* @param updateCenterUrl A sting containing the URL of the update center host.
* @throws IOException if a connection to the update center server can't be established.
*/
public void checkUpdateCenter(ConnectionCheckJob job, String updateCenterUrl) throws IOException {
testConnection(new URL(updateCenterUrl + "?uctest"));
}
/**
* Validate the URL of the resource before downloading it.
*
* @param job The download job that is invoking this strategy. This job is
* responsible for managing the status of the download and installation.
* @param src The location of the resource on the network
* @throws IOException if the validation fails
*/
public void preValidate(DownloadJob job, URL src) throws IOException {
}
/**
* Validate the resource after it has been downloaded, before it is
* installed. The default implementation does nothing.
*
* @param job The download job that is invoking this strategy. This job is
* responsible for managing the status of the download and installation.
* @param src The location of the downloaded resource.
* @throws IOException if the validation fails.
*/
public void postValidate(DownloadJob job, File src) throws IOException {
}
/**
* Download a plugin or core upgrade in preparation for installing it
* into its final location. Implementations will normally download the
* resource into a temporary location and hand off a reference to this
* location to the install or upgrade strategy to move into the final location.
*
* @param job The download job that is invoking this strategy. This job is
* responsible for managing the status of the download and installation.
* @param src The URL to the resource to be downloaded.
* @return A File object that describes the downloaded resource.
* @throws IOException if there were problems downloading the resource.
* @see DownloadJob
*/
public File download(DownloadJob job, URL src) throws IOException {
try {
URLConnection con = connect(job,src);
int total = con.getContentLength();
CountingInputStream in = new CountingInputStream(con.getInputStream());
byte[] buf = new byte[8192];
int len;
File dst = job.getDestination();
File tmp = new File(dst.getPath()+".tmp");
OutputStream out = new FileOutputStream(tmp);
LOGGER.info("Downloading "+job.getName());
try {
while((len=in.read(buf))>=0) {
out.write(buf,0,len);
job.status = job.new Installing(total==-1 ? -1 : in.getCount()*100/total);
}
} catch (IOException e) {
throw new IOException2("Failed to load "+src+" to "+tmp,e);
}
in.close();
out.close();
if (total!=-1 && total!=tmp.length()) {
// don't know exactly how this happens, but report like
// http://www.ashlux.com/wordpress/2009/08/14/hudson-and-the-sonar-plugin-fail-maveninstallation-nosuchmethoderror/
// indicates that this kind of inconsistency can happen. So let's be defensive
throw new IOException("Inconsistent file length: expected "+total+" but only got "+tmp.length());
}
return tmp;
} catch (IOException e) {
throw new IOException2("Failed to download from "+src,e);
}
}
/**
* Connects to the given URL for downloading the binary. Useful for tweaking
* how the connection gets established.
*/
protected URLConnection connect(DownloadJob job, URL src) throws IOException {
return ProxyConfiguration.open(src);
}
/**
* Called after a plugin has been downloaded to move it into its final
* location. The default implementation is a file rename.
*
* @param job The install job that is invoking this strategy.
* @param src The temporary location of the plugin.
* @param dst The final destination to install the plugin to.
* @throws IOException if there are problems installing the resource.
*/
public void install(DownloadJob job, File src, File dst) throws IOException {
job.replace(dst, src);
}
/**
* Called after an upgrade has been downloaded to move it into its final
* location. The default implementation is a file rename.
*
* @param job The upgrade job that is invoking this strategy.
* @param src The temporary location of the upgrade.
* @param dst The final destination to install the upgrade to.
* @throws IOException if there are problems installing the resource.
*/
public void upgrade(DownloadJob job, File src, File dst) throws IOException {
job.replace(dst, src);
}
/**
* Returns an "always up" server for Internet connectivity testing.
*
* @deprecated as of 1.333
* With the introduction of multiple update center capability, this information
* is now a part of the update-center.json file. See
* http://jenkins-ci.org/update-center.json as an example.
*/
public String getConnectionCheckUrl() {
return "http://www.google.com";
}
/**
* Returns the URL of the server that hosts the update-center.json
* file.
*
* @deprecated as of 1.333
* With the introduction of multiple update center capability, this information
* is now moved to {@link UpdateSite}.
* @return
* Absolute URL that ends with '/'.
*/
public String getUpdateCenterUrl() {
return UPDATE_CENTER_URL;
}
/**
* Returns the URL of the server that hosts plugins and core updates.
*
* @deprecated as of 1.333
* update-center.json is now signed, so we don't have to further make sure that
* we aren't downloading from anywhere unsecure.
*/
public String getPluginRepositoryBaseUrl() {
return "http://jenkins-ci.org/";
}
private void testConnection(URL url) throws IOException {
try {
Util.copyStreamAndClose(ProxyConfiguration.open(url).getInputStream(),new NullOutputStream());
} catch (SSLHandshakeException e) {
if (e.getMessage().contains("PKIX path building failed"))
// fix up this crappy error message from JDK
throw new IOException2("Failed to validate the SSL certificate of "+url,e);
}
}
}
/**
* Things that {@link UpdateCenter#installerService} executes.
*
* This object will have the row.jelly which renders the job on UI.
*/
public abstract class UpdateCenterJob implements Runnable {
/**
* Which {@link UpdateSite} does this belong to?
*/
public final UpdateSite site;
/**
* If this job fails, set to the error.
*/
protected Throwable error;
protected UpdateCenterJob(UpdateSite site) {
this.site = site;
}
/**
* @deprecated as of 1.326
* Use {@link #submit()} instead.
*/
public void schedule() {
submit();
}
/**
* Schedules this job for an execution
* @return
* {@link Future} to keeps track of the status of the execution.
*/
public Future
* Instances of this class is immutable.
*/
public abstract class InstallationStatus extends Throwable {
public final int id = iota.incrementAndGet();
public boolean isSuccess() {
return false;
}
}
/**
* Indicates that the installation of a plugin failed.
*/
public class Failure extends InstallationStatus {
public final Throwable problem;
public Failure(Throwable problem) {
this.problem = problem;
}
public String getProblemStackTrace() {
return Functions.printThrowable(problem);
}
}
/**
* Indicates that the installation was successful but a restart is needed.
*
* @see
*/
public class SuccessButRequiresRestart extends Success {
private final Localizable message;
public SuccessButRequiresRestart(Localizable message) {
this.message = message;
}
public String getMessage() {
return message.toString();
}
}
/**
* Indicates that the plugin was successfully installed.
*/
public class Success extends InstallationStatus {
@Override public boolean isSuccess() {
return true;
}
}
/**
* Indicates that the plugin is waiting for its turn for installation.
*/
public class Pending extends InstallationStatus {
}
/**
* Installation of a plugin is in progress.
*/
public class Installing extends InstallationStatus {
/**
* % completed download, or -1 if the percentage is not known.
*/
public final int percentage;
public Installing(int percentage) {
this.percentage = percentage;
}
}
}
/**
* Represents the state of the installation activity of one plugin.
*/
public final class InstallationJob extends DownloadJob {
/**
* What plugin are we trying to install?
*/
public final Plugin plugin;
private final PluginManager pm = Jenkins.getInstance().getPluginManager();
/**
* True to load the plugin into this Jenkins, false to wait until restart.
*/
private final boolean dynamicLoad;
/**
* @deprecated as of 1.442
*/
public InstallationJob(Plugin plugin, UpdateSite site, Authentication auth) {
this(plugin,site,auth,false);
}
public InstallationJob(Plugin plugin, UpdateSite site, Authentication auth, boolean dynamicLoad) {
super(site, auth);
this.plugin = plugin;
this.dynamicLoad = dynamicLoad;
}
protected URL getURL() throws MalformedURLException {
return new URL(plugin.url);
}
protected File getDestination() {
File baseDir = pm.rootDir;
return new File(baseDir, plugin.name + ".hpi");
}
public String getName() {
return plugin.getDisplayName();
}
@Override
public void _run() throws IOException, InstallationStatus {
super._run();
// if this is a bundled plugin, make sure it won't get overwritten
PluginWrapper pw = plugin.getInstalled();
if (pw!=null && pw.isBundled())
try {
SecurityContextHolder.getContext().setAuthentication(ACL.SYSTEM);
pw.doPin();
} finally {
SecurityContextHolder.clearContext();
}
if (dynamicLoad) {
try {
pm.dynamicLoad(getDestination());
} catch (RestartRequiredException e) {
throw new SuccessButRequiresRestart(e.message);
} catch (Exception e) {
throw new IOException2("Failed to dynamically deploy this plugin",e);
}
} else {
throw new SuccessButRequiresRestart(Messages._UpdateCenter_DownloadButNotActivated());
}
}
protected void onSuccess() {
pm.pluginUploaded = true;
}
@Override
public String toString() {
return super.toString()+"[plugin="+plugin.title+"]";
}
}
/**
* Represents the state of the downgrading activity of plugin.
*/
public final class PluginDowngradeJob extends DownloadJob {
/**
* What plugin are we trying to install?
*/
public final Plugin plugin;
private final PluginManager pm = Jenkins.getInstance().getPluginManager();
public PluginDowngradeJob(Plugin plugin, UpdateSite site, Authentication auth) {
super(site, auth);
this.plugin = plugin;
}
protected URL getURL() throws MalformedURLException {
return new URL(plugin.url);
}
protected File getDestination() {
File baseDir = pm.rootDir;
return new File(baseDir, plugin.name + ".hpi");
}
protected File getBackup() {
File baseDir = pm.rootDir;
return new File(baseDir, plugin.name + ".bak");
}
public String getName() {
return plugin.getDisplayName();
}
@Override
public void run() {
try {
LOGGER.info("Starting the downgrade of "+getName()+" on behalf of "+getUser().getName());
_run();
LOGGER.info("Downgrade successful: "+getName());
status = new Success();
onSuccess();
} catch (Throwable e) {
LOGGER.log(Level.SEVERE, "Failed to downgrade "+getName(),e);
status = new Failure(e);
error = e;
}
}
@Override
protected void _run() throws IOException {
File dst = getDestination();
File backup = getBackup();
config.install(this, backup, dst);
}
/**
* Called to overwrite
* current version with backup file
*/
@Override
protected void replace(File dst, File backup) throws IOException {
dst.delete(); // any failure up to here is no big deal
if(!backup.renameTo(dst)) {
throw new IOException("Failed to rename "+backup+" to "+dst);
}
}
protected void onSuccess() {
pm.pluginUploaded = true;
}
@Override
public String toString() {
return super.toString()+"[plugin="+plugin.title+"]";
}
}
/**
* Represents the state of the upgrade activity of Jenkins core.
*/
public final class HudsonUpgradeJob extends DownloadJob {
public HudsonUpgradeJob(UpdateSite site, Authentication auth) {
super(site, auth);
}
protected URL getURL() throws MalformedURLException {
return new URL(site.getData().core.url);
}
protected File getDestination() {
return Lifecycle.get().getHudsonWar();
}
public String getName() {
return "jenkins.war";
}
protected void onSuccess() {
status = new Success();
}
@Override
protected void replace(File dst, File src) throws IOException {
Lifecycle.get().rewriteHudsonWar(src);
}
}
public final class HudsonDowngradeJob extends DownloadJob {
public HudsonDowngradeJob(UpdateSite site, Authentication auth) {
super(site, auth);
}
protected URL getURL() throws MalformedURLException {
return new URL(site.getData().core.url);
}
protected File getDestination() {
return Lifecycle.get().getHudsonWar();
}
public String getName() {
return "jenkins.war";
}
protected void onSuccess() {
status = new Success();
}
@Override
public void run() {
try {
LOGGER.info("Starting the downgrade of "+getName()+" on behalf of "+getUser().getName());
_run();
LOGGER.info("Downgrading successful: "+getName());
status = new Success();
onSuccess();
} catch (Throwable e) {
LOGGER.log(Level.SEVERE, "Failed to downgrade "+getName(),e);
status = new Failure(e);
error = e;
}
}
@Override
protected void _run() throws IOException {
File backup = new File(Lifecycle.get().getHudsonWar() + ".bak");
File dst = getDestination();
config.install(this, backup, dst);
}
@Override
protected void replace(File dst, File src) throws IOException {
Lifecycle.get().rewriteHudsonWar(src);
}
}
public static final class PluginEntry implements Comparable"+ Functions.xmlEscape(Functions.printThrowable(e))+"
");
}
public String[] getStatuses() {
synchronized (statuses) {
return statuses.toArray(new String[statuses.size()]);
}
}
}
/**
* Base class for a job that downloads a file from the Jenkins project.
*/
public abstract class DownloadJob extends UpdateCenterJob {
/**
* Unique ID that identifies this job.
*/
public final int id = iota.incrementAndGet();
/**
* Immutable object representing the current state of this job.
*/
public volatile InstallationStatus status = new Pending();
/**
* Where to download the file from.
*/
protected abstract URL getURL() throws MalformedURLException;
/**
* Where to download the file to.
*/
protected abstract File getDestination();
public abstract String getName();
/**
* Called when the whole thing went successfully.
*/
protected abstract void onSuccess();
private Authentication authentication;
/**
* Get the user that initiated this job
*/
public Authentication getUser() {
return this.authentication;
}
protected DownloadJob(UpdateSite site, Authentication authentication) {
super(site);
this.authentication = authentication;
}
public void run() {
try {
LOGGER.info("Starting the installation of "+getName()+" on behalf of "+getUser().getName());
_run();
LOGGER.info("Installation successful: "+getName());
status = new Success();
onSuccess();
} catch (InstallationStatus e) {
status = e;
if (status.isSuccess()) onSuccess();
} catch (Throwable e) {
LOGGER.log(Level.SEVERE, "Failed to install "+getName(),e);
status = new Failure(e);
error = e;
}
}
protected void _run() throws IOException, InstallationStatus {
URL src = getURL();
config.preValidate(this, src);
File dst = getDestination();
File tmp = config.download(this, src);
config.postValidate(this, tmp);
config.install(this, tmp, dst);
}
/**
* Called when the download is completed to overwrite
* the old file with the new file.
*/
protected void replace(File dst, File src) throws IOException {
File bak = Util.changeExtension(dst,".bak");
bak.delete();
dst.renameTo(bak);
dst.delete(); // any failure up to here is no big deal
if(!src.renameTo(dst)) {
throw new IOException("Failed to rename "+src+" to "+dst);
}
}
/**
* Indicates the status or the result of a plugin installation.
*