PluginWrapper.java 19.5 KB
Newer Older
K
kohsuke 已提交
1 2
package hudson;

K
kohsuke 已提交
3
import hudson.util.IOException2;
4
import hudson.util.VersionNumber;
K
kohsuke 已提交
5
import hudson.model.Hudson;
6
import hudson.model.UpdateCenter;
K
kohsuke 已提交
7 8 9
import org.apache.tools.ant.BuildException;
import org.apache.tools.ant.Project;
import org.apache.tools.ant.taskdefs.Expand;
K
kohsuke 已提交
10
import org.apache.tools.ant.types.FileSet;
11
import org.apache.commons.logging.LogFactory;
K
kohsuke 已提交
12 13 14
import org.kohsuke.stapler.StaplerRequest;
import org.kohsuke.stapler.StaplerResponse;

K
kohsuke 已提交
15
import java.io.BufferedReader;
K
kohsuke 已提交
16 17 18
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
K
kohsuke 已提交
19
import java.io.FileReader;
K
kohsuke 已提交
20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62
import java.io.FilenameFilter;
import java.io.IOException;
import java.io.OutputStream;
import java.net.URL;
import java.net.URLClassLoader;
import java.util.ArrayList;
import java.util.List;
import java.util.jar.Manifest;
import java.util.logging.Logger;

/**
 * Represents a Hudson plug-in and associated control information
 * for Hudson to control {@link Plugin}.
 *
 * <p>
 * A plug-in is packaged into a jar file whose extension is <tt>".hpi"</tt>,
 * A plugin needs to have a special manifest entry to identify what it is.
 *
 * <p>
 * At the runtime, a plugin has two distinct state axis.
 * <ol>
 *  <li>Enabled/Disabled. If enabled, Hudson is going to use it
 *      next time Hudson runs. Otherwise the next run will ignore it.
 *  <li>Activated/Deactivated. If activated, that means Hudson is using
 *      the plugin in this session. Otherwise it's not.
 * </ol>
 * <p>
 * For example, an activated but disabled plugin is still running but the next
 * time it won't.
 *
 * @author Kohsuke Kawaguchi
 */
public final class PluginWrapper {
    /**
     * Plugin manifest.
     * Contains description of the plugin.
     */
    private final Manifest manifest;

    /**
     * Loaded plugin instance.
     * Null if disabled.
     */
63
    private Plugin plugin;
K
kohsuke 已提交
64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84

    /**
     * {@link ClassLoader} for loading classes from this plugin.
     * Null if disabled.
     */
    public final ClassLoader classLoader;

    /**
     * Base URL for loading static resources from this plugin.
     * Null if disabled. The static resources are mapped under
     * <tt>hudson/plugin/SHORTNAME/</tt>.
     */
    public final URL baseResourceURL;

    /**
     * Used to control enable/disable setting of the plugin.
     * If this file exists, plugin will be disabled.
     */
    private final File disableFile;

    /**
85 86 87 88
     * Short name of the plugin. The artifact Id of the plugin.
     * This is also used in the URL within Hudson, so it needs
     * to remain stable even when the *.hpi file name is changed
     * (like Maven does.)
K
kohsuke 已提交
89 90 91
     */
    private final String shortName;

92 93 94 95 96 97 98 99 100
    /**
     * True if this plugin is activated for this session.
     * The snapshot of <tt>disableFile.exists()</tt> as of the start up.
     */
    private final boolean active;

    private final File archive;

    private final List<Dependency> dependencies = new ArrayList<Dependency>();
101
    private final List<Dependency> optionalDependencies = new ArrayList<Dependency>();
102 103 104 105

    private static final class Dependency {
        public final String shortName;
        public final String version;
106
        public final boolean optional;
107 108 109

        public Dependency(String s) {
            int idx = s.indexOf(':');
K
kohsuke 已提交
110 111
            if(idx==-1)
                throw new IllegalArgumentException("Illegal dependency specifier "+s);
112 113
            this.shortName = s.substring(0,idx);
            this.version = s.substring(idx+1);
114 115 116 117 118 119 120 121 122 123
            
            boolean isOptional = false;
            String[] osgiProperties = s.split(";");
            for (int i = 1; i < osgiProperties.length; i++) {
                String osgiProperty = osgiProperties[i].trim();
                if (osgiProperty.equalsIgnoreCase("resolution:=optional")) {
                    isOptional = true;
                }
            }
            this.optional = isOptional;
124
        }
125 126 127 128 129

        @Override
        public String toString() {
            return shortName + " (" + version + ")";
        }        
130 131
    }

K
kohsuke 已提交
132 133 134 135 136 137 138 139
    /**
     * @param archive
     *      A .hpi archive file jar file, or a .hpl linked plugin.
     *
     * @throws IOException
     *      if an installation of this plugin failed. The caller should
     *      proceed to work with other plugins.
     */
K
kohsuke 已提交
140
    public PluginWrapper(PluginManager owner, File archive) throws IOException {
K
kohsuke 已提交
141
        LOGGER.info("Loading plugin: "+archive);
142
        this.archive = archive;
K
kohsuke 已提交
143 144 145 146 147 148 149

        boolean isLinked = archive.getName().endsWith(".hpl");

        File expandDir = null;  // if .hpi, this is the directory where war is expanded

        if(isLinked) {
            // resolve the .hpl file to the location of the manifest file
K
kohsuke 已提交
150 151 152 153 154 155 156 157 158 159 160
            BufferedReader archiveReader = new BufferedReader(new FileReader(archive));
            try {
                String firstLine = archiveReader.readLine();
                if(firstLine.startsWith("Manifest-Version:")) {
                    // this is the manifest already
                } else {
                    // indirection
                    archive = resolve(archive, firstLine);
                }
            } finally {
                archiveReader.close();
161
            }
K
kohsuke 已提交
162 163 164 165 166 167 168 169 170 171
            // then parse manifest
            FileInputStream in = new FileInputStream(archive);
            try {
                manifest = new Manifest(in);
            } catch(IOException e) {
                throw new IOException2("Failed to load "+archive,e);
            } finally {
                in.close();
            }
        } else {
172
            expandDir = new File(archive.getParentFile(), getBaseName(archive));
K
kohsuke 已提交
173 174 175 176 177 178 179 180 181 182 183 184 185 186
            explode(archive,expandDir);

            File manifestFile = new File(expandDir,"META-INF/MANIFEST.MF");
            if(!manifestFile.exists()) {
                throw new IOException("Plugin installation failed. No manifest at "+manifestFile);
            }
            FileInputStream fin = new FileInputStream(manifestFile);
            try {
                manifest = new Manifest(fin);
            } finally {
                fin.close();
            }
        }

187 188
        this.shortName = computeShortName(manifest,archive);

K
kohsuke 已提交
189 190 191 192 193
        // TODO: define a mechanism to hide classes
        // String export = manifest.getMainAttributes().getValue("Export");

        List<URL> paths = new ArrayList<URL>();
        if(isLinked) {
194 195
            parseClassPath(archive, paths, "Libraries", ",");
            parseClassPath(archive, paths, "Class-Path", " +"); // backward compatibility
K
kohsuke 已提交
196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211

            this.baseResourceURL = resolve(archive,
                manifest.getMainAttributes().getValue("Resource-Path")).toURL();
        } else {
            File classes = new File(expandDir,"WEB-INF/classes");
            if(classes.exists())
                paths.add(classes.toURL());
            File lib = new File(expandDir,"WEB-INF/lib");
            File[] libs = lib.listFiles(JAR_FILTER);
            if(libs!=null) {
                for (File jar : libs)
                    paths.add(jar.toURL());
            }

            this.baseResourceURL = expandDir.toURL();
        }
K
kohsuke 已提交
212
        ClassLoader dependencyLoader = new DependencyClassLoader(getClass().getClassLoader(),owner);
K
kohsuke 已提交
213
        this.classLoader = new URLClassLoader(paths.toArray(new URL[0]), dependencyLoader);
K
kohsuke 已提交
214 215 216 217

        disableFile = new File(archive.getPath()+".disabled");
        if(disableFile.exists()) {
            LOGGER.info("Plugin is disabled");
218 219 220
            this.active = false;
        } else {
            this.active = true;
K
kohsuke 已提交
221 222
        }

223
        // compute dependencies
K
kohsuke 已提交
224 225
        String v = manifest.getMainAttributes().getValue("Plugin-Dependencies");
        if(v!=null) {
226 227 228 229 230 231 232 233
            for(String s : v.split(",")) {
                Dependency d = new Dependency(s);
                if (d.optional) {
                    optionalDependencies.add(d);
                } else {
                    dependencies.add(d);
                }
            }
K
kohsuke 已提交
234
        }
235 236
    }

237 238 239 240 241 242 243 244 245 246 247 248 249 250 251
    private String computeShortName(Manifest manifest, File archive) {
        // use the name captured in the manifest, as often plugins
        // depend on the specific short name in its URLs.
        String n = manifest.getMainAttributes().getValue("Short-Name");
        if(n!=null)     return n;

        // maven seems to put this automatically, so good fallback to check.
        n = manifest.getMainAttributes().getValue("Extension-Name");
        if(n!=null)     return n;

        // otherwise infer from the file name, since older plugins don't have
        // this entry.
        return getBaseName(archive);
    }

252 253 254 255 256 257 258 259
    /**
     * Loads the plugin and starts it.
     *
     * <p>
     * This should be done after all the classloaders are constructed for
     * all the plugins, so that dependencies can be properly loaded by plugins.
     */
    /*package*/ void load(PluginManager owner) throws IOException {
K
kohsuke 已提交
260 261 262 263 264
        String className = manifest.getMainAttributes().getValue("Plugin-Class");
        if(className ==null) {
            throw new IOException("Plugin installation failed. No 'Plugin-Class' entry in the manifest of "+archive);
        }

265
        loadPluginDependencies(owner);
K
kohsuke 已提交
266

267 268 269
        if(!active)
            return;

270 271 272 273
        // override the context classloader so that XStream activity in plugin.start()
        // will be able to resolve classes in this plugin
        ClassLoader old = Thread.currentThread().getContextClassLoader();
        Thread.currentThread().setContextClassLoader(classLoader);
K
kohsuke 已提交
274
        try {
275 276 277 278 279 280 281 282 283
            try {
                Class clazz = classLoader.loadClass(className);
                Object plugin = clazz.newInstance();
                if(!(plugin instanceof Plugin)) {
                    throw new IOException(className+" doesn't extend from hudson.Plugin");
                }
                this.plugin = (Plugin)plugin;
                this.plugin.wrapper = this;
            } catch (ClassNotFoundException e) {
K
kohsuke 已提交
284
                throw new IOException2("Unable to load " + className + " from " + archive,e);
285
            } catch (IllegalAccessException e) {
K
kohsuke 已提交
286
                throw new IOException2("Unable to create instance of " + className + " from " + archive,e);
287
            } catch (InstantiationException e) {
K
kohsuke 已提交
288
                throw new IOException2("Unable to create instance of " + className + " from " + archive,e);
K
kohsuke 已提交
289 290
            }

291 292 293 294 295 296
            // initialize plugin
            try {
                plugin.setServletContext(owner.context);
                plugin.start();
            } catch(Throwable t) {
                // gracefully handle any error in plugin.
K
kohsuke 已提交
297
                throw new IOException2("Failed to initialize",t);
298 299 300
            }
        } finally {
            Thread.currentThread().setContextClassLoader(old);
K
kohsuke 已提交
301 302 303
        }
    }

304 305 306 307 308 309 310 311 312 313 314 315 316 317 318
    /**
     * Loads the dependencies to other plugins.
     * @param owner plugin manager to determine if the dependency is installed or not.
     * @throws IOException thrown if one or several mandatory dependencies doesnt exists.
     */
    private void loadPluginDependencies(PluginManager owner) throws IOException {
        List<String> missingDependencies = new ArrayList<String>();
        // make sure dependencies exist
        for (Dependency d : dependencies) {
            if(owner.getPlugin(d.shortName)==null)
                missingDependencies.add(d.toString());
        }
        if (! missingDependencies.isEmpty()) {
            StringBuilder builder = new StringBuilder();
            builder.append("Dependency ");
319
            builder.append(Util.join(missingDependencies, ", "));
320 321 322 323 324 325 326 327 328 329 330
            builder.append(" doesn't exist");
            throw new IOException(builder.toString());
        }
        
        // add the optional dependencies that exists
        for (Dependency d : optionalDependencies) {
            if(owner.getPlugin(d.shortName)!=null)
                dependencies.add(d);
        }
    }

331 332
    private void parseClassPath(File archive, List<URL> paths, String attributeName, String separator) throws IOException {
        String classPath = manifest.getMainAttributes().getValue(attributeName);
333
        if(classPath==null) return; // attribute not found
334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352
        for (String s : classPath.split(separator)) {
            File file = resolve(archive, s);
            if(file.getName().contains("*")) {
                // handle wildcard
                FileSet fs = new FileSet();
                File dir = file.getParentFile();
                fs.setDir(dir);
                fs.setIncludes(file.getName());
                for( String included : fs.getDirectoryScanner(new Project()).getIncludedFiles() ) {
                    paths.add(new File(dir,included).toURL());
                }
            } else {
                if(!file.exists())
                    throw new IOException("No such file: "+file);
                paths.add(file.toURL());
            }
        }
    }

K
kohsuke 已提交
353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374
    private static File resolve(File base, String relative) {
        File rel = new File(relative);
        if(rel.isAbsolute())
            return rel;
        else
            return new File(base.getParentFile(),relative);
    }

    /**
     * Returns the URL of the index page jelly script.
     */
    public URL getIndexPage() {
        return classLoader.getResource("index.jelly");
    }

    /**
     * Returns the short name suitable for URL.
     */
    public String getShortName() {
        return shortName;
    }

375 376 377 378 379 380 381
    /**
     * Gets the instance of {@link Plugin} contributed by this plugin.
     */
    public Plugin getPlugin() {
        return plugin;
    }

382 383 384 385 386
    @Override
    public String toString() {
        return "Plugin:" + getShortName();
    }

K
kohsuke 已提交
387 388 389 390
    /**
     * Returns a one-line descriptive name of this plugin.
     */
    public String getLongName() {
391
        String name = manifest.getMainAttributes().getValue("Long-PluginName");
K
kohsuke 已提交
392 393 394 395
        if(name!=null)      return name;
        return shortName;
    }

396 397 398 399 400 401
    /**
     * Returns the version number of this plugin
     */
    public String getVersion() {
        String v = manifest.getMainAttributes().getValue("Plugin-Version");
        if(v!=null)      return v;
K
kohsuke 已提交
402 403 404 405 406

        // plugins generated before maven-hpi-plugin 1.3 should still have this attribute
        v = manifest.getMainAttributes().getValue("Implementation-Version");
        if(v!=null)      return v;

407 408 409
        return "???";
    }

K
kohsuke 已提交
410 411 412
    /**
     * Gets the "abc" portion from "abc.ext".
     */
413
    private static String getBaseName(File archive) {
K
kohsuke 已提交
414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431
        String n = archive.getName();
        int idx = n.lastIndexOf('.');
        if(idx>=0)
            n = n.substring(0,idx);
        return n;
    }

    /**
     * Terminates the plugin.
     */
    void stop() {
        LOGGER.info("Stopping "+shortName);
        try {
            plugin.stop();
        } catch(Throwable t) {
            System.err.println("Failed to shut down "+shortName);
            System.err.println(t);
        }
432 433 434
        // Work around a bug in commons-logging.
        // See http://www.szegedi.org/articles/memleak.html
        LogFactory.release(classLoader);
K
kohsuke 已提交
435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457
    }

    /**
     * Enables this plugin next time Hudson runs.
     */
    public void enable() throws IOException {
        if(!disableFile.delete())
            throw new IOException("Failed to delete "+disableFile);
    }

    /**
     * Disables this plugin next time Hudson runs.
     */
    public void disable() throws IOException {
        // creates an empty file
        OutputStream os = new FileOutputStream(disableFile);
        os.close();
    }

    /**
     * Returns true if this plugin is enabled for this session.
     */
    public boolean isActive() {
458
        return active;
K
kohsuke 已提交
459 460 461 462 463 464 465 466 467 468
    }

    /**
     * If true, the plugin is going to be activated next time
     * Hudson runs.
     */
    public boolean isEnabled() {
        return !disableFile.exists();
    }

469 470 471
    /**
     * If the plugin has {@link #getUpdateInfo() an update},
     * returns the {@link UpdateCenter.Plugin} object.
K
kohsuke 已提交
472 473 474 475
     *
     * @return
     *      This method may return null &mdash; for example,
     *      the user may have installed a plugin locally developed.
476 477 478 479 480 481 482 483 484 485 486 487 488 489 490
     */
    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;
        }
    }
491 492 493 494 495 496 497 498
    
    /**
     * returns the {@link UpdateCenter.Plugin} object, or null.
     */
    public UpdateCenter.Plugin getInfo() {
        UpdateCenter uc = Hudson.getInstance().getUpdateCenter();
        return uc.getPlugin(getShortName());
    }
499 500 501 502 503 504 505 506 507 508 509 510

    /**
     * Returns true if this plugin has update in the update center.
     *
     * <p>
     * This method is conservative in the sense that if the version number is incomprehensible,
     * it always returns false.
     */
    public boolean hasUpdate() {
        return getUpdateInfo()!=null;
    }

K
kohsuke 已提交
511 512 513 514 515 516 517 518 519 520 521 522 523 524
    /**
     * Explodes the plugin into a directory, if necessary.
     */
    private void explode(File archive, File destDir) throws IOException {
        if(!destDir.exists())
            destDir.mkdirs();

        // timestamp check
        File explodeTime = new File(destDir,".timestamp");
        if(explodeTime.exists() && explodeTime.lastModified()>archive.lastModified())
            return; // no need to expand

        LOGGER.info("Extracting "+archive);

K
kohsuke 已提交
525 526 527
        // delete the contents so that old files won't interfere with new files
        Util.deleteContentsRecursive(destDir);

K
kohsuke 已提交
528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550
        try {
            Expand e = new Expand();
            e.setProject(new Project());
            e.setTaskType("unzip");
            e.setSrc(archive);
            e.setDest(destDir);
            e.execute();
        } catch (BuildException x) {
            IOException ioe = new IOException("Failed to expand " + archive);
            ioe.initCause(x);
            throw ioe;
        }

        Util.touch(explodeTime);
    }


//
//
// Action methods
//
//
    public void doMakeEnabled(StaplerRequest req, StaplerResponse rsp) throws IOException {
551
    Hudson.getInstance().checkPermission(Hudson.ADMINISTER);
K
kohsuke 已提交
552 553 554 555
        enable();
        rsp.setStatus(200);
    }
    public void doMakeDisabled(StaplerRequest req, StaplerResponse rsp) throws IOException {
556
        Hudson.getInstance().checkPermission(Hudson.ADMINISTER);
K
kohsuke 已提交
557 558 559 560 561 562 563 564 565 566 567 568 569 570 571
        disable();
        rsp.setStatus(200);
    }


    private static final Logger LOGGER = Logger.getLogger(PluginWrapper.class.getName());

    /**
     * Filter for jar files.
     */
    private static final FilenameFilter JAR_FILTER = new FilenameFilter() {
        public boolean accept(File dir,String name) {
            return name.endsWith(".jar");
        }
    };
K
kohsuke 已提交
572 573 574 575 576

    /**
     * Used to load classes from dependency plugins.
     */
    final class DependencyClassLoader extends ClassLoader {
K
kohsuke 已提交
577 578 579
        private final PluginManager manager;

        public DependencyClassLoader(ClassLoader parent, PluginManager manager) {
K
kohsuke 已提交
580
            super(parent);
K
kohsuke 已提交
581
            this.manager = manager;
K
kohsuke 已提交
582 583 584 585
        }

        protected Class<?> findClass(String name) throws ClassNotFoundException {
            for (Dependency dep : dependencies) {
K
kohsuke 已提交
586
                PluginWrapper p = manager.getPlugin(dep.shortName);
K
kohsuke 已提交
587 588
                if(p!=null)
                    try {
K
kohsuke 已提交
589
                        return p.classLoader.loadClass(name);
K
kohsuke 已提交
590 591 592 593 594 595 596 597 598 599
                    } catch (ClassNotFoundException _) {
                        // try next
                    }
            }

            throw new ClassNotFoundException(name);
        }

        // TODO: delegate resources? watch out for diamond dependencies
    }
K
kohsuke 已提交
600
}