org.jvnet.hudson.update_center.Main.java Source code

Java tutorial

Introduction

Here is the source code for org.jvnet.hudson.update_center.Main.java

Source

/*
 * The MIT License
 *
 * Copyright (c) 2004-2009, Sun Microsystems, Inc.
 *
 * 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 org.jvnet.hudson.update_center;

import hudson.util.VersionNumber;
import net.sf.json.JSONArray;
import net.sf.json.JSONObject;
import org.apache.commons.codec.binary.Base64;
import org.apache.commons.io.output.NullOutputStream;
import org.apache.commons.io.output.TeeOutputStream;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.bouncycastle.openssl.PEMReader;
import org.jvnet.hudson.crypto.CertificateUtil;
import org.jvnet.hudson.crypto.SignatureOutputStream;
import org.kohsuke.args4j.CmdLineException;
import org.kohsuke.args4j.CmdLineParser;
import org.kohsuke.args4j.Option;

import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.FileReader;
import java.io.FileWriter;
import java.io.IOException;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;
import java.security.DigestOutputStream;
import java.security.GeneralSecurityException;
import java.security.KeyPair;
import java.security.MessageDigest;
import java.security.PrivateKey;
import java.security.Signature;
import java.security.cert.CertificateException;
import java.security.cert.CertificateFactory;
import java.security.cert.TrustAnchor;
import java.security.cert.X509Certificate;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.Comparator;
import java.util.Date;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
import java.util.concurrent.TimeUnit;

import static java.security.Security.addProvider;

/**
 * @author Kohsuke Kawaguchi
 */
public class Main {
    @Option(name = "-o", usage = "json file")
    public File output = new File("output.json");

    @Option(name = "-r", usage = "release history JSON file")
    public File releaseHistory = new File("release-history.json");

    @Option(name = "-h", usage = "htaccess file")
    public File htaccess = new File(".htaccess");

    /**
     * This option builds the directory image for the download server.
     */
    @Option(name = "-download", usage = "Build download server layout")
    public File download = null;

    @Option(name = "-www", usage = "Built jenkins-ci.org layout")
    public File www = null;

    @Option(name = "-index.html", usage = "Update the version number of the latest jenkins.war in jenkins-ci.org/index.html")
    public File indexHtml = null;

    @Option(name = "-latestCore.txt", usage = "Update the version number of the latest jenkins.war in latestCore.txt")
    public File latestCoreTxt = null;

    @Option(name = "-key", usage = "Private key to sign the update center. Must be used in conjunction with -certificate.")
    public File privateKey = null;

    @Option(name = "-certificate", usage = "X509 certificate for the private key given by the -key option. Specify additional -certificate options to pass in intermediate certificates, if any.")
    public List<File> certificates = new ArrayList<File>();

    @Option(name = "-root-certificate", usage = "Additional root certificates")
    public List<File> rootCA = new ArrayList<File>();

    // debug option. spits out the canonical update center file used to compute the signature
    @Option(name = "-canonical")
    public File canonical = null;

    @Option(name = "-id", required = true, usage = "Uniquely identifies this update center. We recommend you use a dot-separated name like \"com.sun.wts.jenkins\". This value is not exposed to users, but instead internally used by Jenkins.")
    public String id;

    @Option(name = "-maxPlugins", usage = "For testing purposes. Limit the number of plugins managed to the specified number.")
    public Integer maxPlugins;

    @Option(name = "-connectionCheckUrl", usage = "Specify an URL of the 'always up' server for performing connection check.")
    public String connectionCheckUrl;

    @Option(name = "-pretty", usage = "Pretty-print the result")
    public boolean prettyPrint;

    @Option(name = "-cap", usage = "Cap the version number and only report data that's compatible with ")
    public String cap = null;

    public static final String EOL = System.getProperty("line.separator");

    public static void main(String[] args) throws Exception {
        System.exit(new Main().run(args));
    }

    public int run(String[] args) throws Exception {
        CmdLineParser p = new CmdLineParser(this);
        try {
            p.parseArgument(args);

            if (www != null) {
                prepareStandardDirectoryLayout();
            }

            run();
            return 0;
        } catch (CmdLineException e) {
            System.err.println(e.getMessage());
            p.printUsage(System.err);
            return 1;
        }
    }

    private void prepareStandardDirectoryLayout() {
        output = new File(www, "update-center.json");
        htaccess = new File(www, "latest/.htaccess");
        indexHtml = new File(www, "index.html");
        releaseHistory = new File(www, "release-history.json");
        latestCoreTxt = new File(www, "latestCore.txt");
    }

    public void run() throws Exception {

        MavenRepository repo = createRepository();

        PrintWriter latestRedirect = createHtaccessWriter();

        JSONObject ucRoot = buildUpdateCenterJson(repo, latestRedirect);
        String uc = updateCenterPostCallJson(ucRoot);
        writeToFile(uc, output);

        JSONObject rhRoot = buildFullReleaseHistory(repo);
        String rh = prettyPrintJson(rhRoot);
        writeToFile(rh, releaseHistory);

        latestRedirect.close();
    }

    String updateCenterPostCallJson(JSONObject ucRoot) {
        return "updateCenter.post(" + EOL + prettyPrintJson(ucRoot) + EOL + ");";
    }

    private PrintWriter createHtaccessWriter() throws IOException {
        File p = htaccess.getParentFile();
        if (p != null)
            p.mkdirs();
        return new PrintWriter(new FileWriter(htaccess), true);
    }

    private JSONObject buildUpdateCenterJson(MavenRepository repo, PrintWriter latestRedirect) throws Exception {
        JSONObject root = new JSONObject();
        root.put("updateCenterVersion", "1"); // we'll bump the version when we make incompatible changes
        JSONObject core = buildCore(repo, latestRedirect);
        if (core != null)
            root.put("core", core);
        root.put("plugins", buildPlugins(repo, latestRedirect));
        root.put("id", id);
        if (connectionCheckUrl != null)
            root.put("connectionCheckUrl", connectionCheckUrl);

        if (privateKey != null && !certificates.isEmpty())
            sign(root);
        else {
            if (privateKey != null || !certificates.isEmpty())
                throw new Exception("private key and certificate must be both specified");
        }

        return root;
    }

    private static void writeToFile(String string, final File file) throws IOException {
        PrintWriter rhpw = new PrintWriter(new FileWriter(file));
        rhpw.print(string);
        rhpw.close();
    }

    private String prettyPrintJson(JSONObject json) {
        return prettyPrint ? json.toString(2) : json.toString();
    }

    protected MavenRepository createRepository() throws Exception {
        MavenRepository repo = DefaultMavenRepositoryBuilder.createStandardInstance();
        if (maxPlugins != null)
            repo = new TruncatedMavenRepository(repo, maxPlugins);
        if (cap != null)
            repo = new VersionCappedMavenRepository(repo, new VersionNumber(cap));
        return repo;
    }

    /**
     * Generates a canonicalized JSON format of the given object, and put the signature in it.
     * Because it mutates the signed object itself, validating the signature needs a bit of work,
     * but this enables a signature to be added transparently.
     */
    protected void sign(JSONObject o) throws GeneralSecurityException, IOException {
        JSONObject sign = new JSONObject();

        List<X509Certificate> certs = getCertificateChain();
        X509Certificate signer = certs.get(0); // the first one is the signer, and the rest is the chain to a root CA.

        PrivateKey key = ((KeyPair) new PEMReader(new FileReader(privateKey)).readObject()).getPrivate();

        // first, backward compatible signature for <1.433 Jenkins that forgets to flush the stream.
        // we generate this in the original names that those Jenkins understands.
        SignatureGenerator sg = new SignatureGenerator(signer, key);
        o.writeCanonical(new OutputStreamWriter(sg.getOut(), "UTF-8"));
        sg.addRecord(sign, "");

        // then the correct signature, into names that don't collide.
        OutputStream raw = new NullOutputStream();
        if (canonical != null) {
            raw = new FileOutputStream(canonical);
        }
        sg = new SignatureGenerator(signer, key);
        o.writeCanonical(new OutputStreamWriter(new TeeOutputStream(sg.getOut(), raw), "UTF-8")).close();
        sg.addRecord(sign, "correct_");

        // and certificate chain
        JSONArray a = new JSONArray();
        for (X509Certificate cert : certs)
            a.add(new String(Base64.encodeBase64(cert.getEncoded())));
        sign.put("certificates", a);

        o.put("signature", sign);
    }

    /**
     * Generates a digest and signature. Can be only used once, and then it needs to be thrown away.
     */
    static class SignatureGenerator {
        private final MessageDigest sha1;
        private final Signature sig;
        private final TeeOutputStream out;
        private final Signature verifier;

        SignatureGenerator(X509Certificate signer, PrivateKey key) throws GeneralSecurityException, IOException {
            // this is for computing a digest
            sha1 = MessageDigest.getInstance("SHA1");
            DigestOutputStream dos = new DigestOutputStream(new NullOutputStream(), sha1);

            // this is for computing a signature
            sig = Signature.getInstance("SHA1withRSA");
            sig.initSign(key);
            SignatureOutputStream sos = new SignatureOutputStream(sig);

            // this is for verifying that signature validates
            verifier = Signature.getInstance("SHA1withRSA");
            verifier.initVerify(signer.getPublicKey());
            SignatureOutputStream vos = new SignatureOutputStream(verifier);

            out = new TeeOutputStream(new TeeOutputStream(dos, sos), vos);
        }

        public TeeOutputStream getOut() {
            return out;
        }

        public void addRecord(JSONObject sign, String prefix) throws GeneralSecurityException, IOException {
            // digest
            byte[] digest = sha1.digest();
            sign.put(prefix + "digest", new String(Base64.encodeBase64(digest)));

            // signature
            byte[] s = sig.sign();
            sign.put(prefix + "signature", new String(Base64.encodeBase64(s)));

            // did the signature validate?
            if (!verifier.verify(s))
                throw new GeneralSecurityException(
                        "Signature failed to validate. Either the certificate and the private key weren't matching, or a bug in the program.");
        }
    }

    /**
     * Loads a certificate chain and makes sure it's valid.
     */
    protected List<X509Certificate> getCertificateChain() throws IOException, GeneralSecurityException {
        CertificateFactory cf = CertificateFactory.getInstance("X509");
        List<X509Certificate> certs = new ArrayList<X509Certificate>();
        for (File f : certificates) {
            X509Certificate c = loadCertificate(cf, f);
            c.checkValidity(new Date(System.currentTimeMillis() + TimeUnit.DAYS.toMillis(30)));
            certs.add(c);
        }

        Set<TrustAnchor> rootCAs = CertificateUtil.getDefaultRootCAs();
        rootCAs.add(new TrustAnchor(
                (X509Certificate) cf.generateCertificate(getClass().getResourceAsStream("/hudson-community.cert")),
                null));
        for (File f : rootCA) {
            rootCAs.add(new TrustAnchor(loadCertificate(cf, f), null));
        }

        try {
            CertificateUtil.validatePath(certs, rootCAs);
        } catch (GeneralSecurityException e) {
            e.printStackTrace();
        }
        return certs;
    }

    private X509Certificate loadCertificate(CertificateFactory cf, File f)
            throws CertificateException, IOException {
        FileInputStream in = new FileInputStream(f);
        try {
            X509Certificate c = (X509Certificate) cf.generateCertificate(in);
            c.checkValidity();
            return c;
        } finally {
            in.close();
        }
    }

    /**
     * Build JSON for the plugin list.
     * @param repository
     * @param redirect
     */
    protected JSONObject buildPlugins(MavenRepository repository, PrintWriter redirect) throws Exception {
        ConfluencePluginList cpl = new ConfluencePluginList();

        int total = 0;

        JSONObject plugins = new JSONObject();
        for (PluginHistory hpi : repository.listHudsonPlugins()) {
            try {
                System.out.println(hpi.artifactId);

                Plugin plugin = new Plugin(hpi, cpl);
                if (plugin.isDeprecated()) {
                    System.out.println("=> Plugin is deprecated.. skipping.");
                    continue;
                }

                System.out.println(plugin.page != null ? "=> " + plugin.page.getTitle() : "** No wiki page found");
                JSONObject json = plugin.toJSON();
                System.out.println("=> " + json);
                plugins.put(plugin.artifactId, json);
                String permalink = String.format("/latest/%s.hpi", plugin.artifactId);
                redirect.printf("Redirect 302 %s %s\n", permalink, plugin.latest.getURL().getPath());

                if (download != null) {
                    for (HPI v : hpi.artifacts.values()) {
                        stage(v, new File(download,
                                "plugins/" + hpi.artifactId + "/" + v.version + "/" + hpi.artifactId + ".hpi"));
                    }
                    if (!hpi.artifacts.isEmpty())
                        createLatestSymlink(hpi, plugin.latest);
                }

                if (www != null)
                    buildIndex(new File(www, "download/plugins/" + hpi.artifactId), hpi.artifactId,
                            hpi.artifacts.values(), permalink);

                total++;
            } catch (IOException e) {
                e.printStackTrace();
                // move on to the next plugin
            }
        }

        System.out.println("Total " + total + " plugins listed.");
        return plugins;
    }

    /**
     * Generates symlink to the latest version.
     */
    protected void createLatestSymlink(PluginHistory hpi, HPI latest) throws InterruptedException, IOException {
        File dir = new File(download, "plugins/" + hpi.artifactId);
        new File(dir, "latest").delete();

        ProcessBuilder pb = new ProcessBuilder();
        pb.command("ln", "-s", latest.version, "latest");
        pb.directory(dir);
        int r = pb.start().waitFor();
        if (r != 0)
            throw new IOException("ln failed: " + r);
    }

    /**
     * Stages an artifact into the specified location.
     */
    protected void stage(MavenArtifact a, File dst) throws IOException, InterruptedException {
        File src = a.resolve();
        if (dst.exists() && dst.lastModified() == src.lastModified() && dst.length() == src.length())
            return; // already up to date

        //        dst.getParentFile().mkdirs();
        //        FileUtils.copyFile(src,dst);

        // TODO: directory and the war file should have the release timestamp
        dst.getParentFile().mkdirs();

        ProcessBuilder pb = new ProcessBuilder();
        pb.command("ln", "-f", src.getAbsolutePath(), dst.getAbsolutePath());
        if (pb.start().waitFor() != 0)
            throw new IOException("ln failed");

    }

    /**
     * Build JSON for the release history list.
     * @param repo
     */
    protected JSONObject buildFullReleaseHistory(MavenRepository repo) throws Exception {
        JSONObject rhRoot = new JSONObject();
        rhRoot.put("releaseHistory", buildReleaseHistory(repo));
        return rhRoot;
    }

    protected JSONArray buildReleaseHistory(MavenRepository repository) throws Exception {
        ConfluencePluginList cpl = new ConfluencePluginList();

        JSONArray releaseHistory = new JSONArray();
        for (Map.Entry<Date, Map<String, HPI>> relsOnDate : repository.listHudsonPluginsByReleaseDate()
                .entrySet()) {
            String relDate = MavenArtifact.getDateFormat().format(relsOnDate.getKey());
            System.out.println("Releases on " + relDate);

            JSONArray releases = new JSONArray();

            for (Map.Entry<String, HPI> rel : relsOnDate.getValue().entrySet()) {
                HPI h = rel.getValue();
                JSONObject o = new JSONObject();
                try {
                    Plugin plugin = new Plugin(h, cpl);

                    String title = plugin.getTitle();
                    if ((title == null) || (title.equals(""))) {
                        title = h.artifact.artifactId;
                    }

                    o.put("title", title);
                    o.put("gav", h.artifact.groupId + ':' + h.artifact.artifactId + ':' + h.artifact.version);
                    o.put("timestamp", h.getTimestamp());
                    o.put("wiki", plugin.getWiki());
                    o.put("version", h.version);
                    System.out.println("\t" + title + ":" + h.version);
                } catch (IOException e) {
                    System.out.println("Failed to resolve plugin " + h.artifact.artifactId + " so using defaults");
                    o.put("title", h.artifact.artifactId);
                    o.put("wiki", "");
                    o.put("version", h.version);
                }
                releases.add(o);
            }
            JSONObject d = new JSONObject();
            d.put("date", relDate);
            d.put("releases", releases);
            releaseHistory.add(d);
        }

        return releaseHistory;
    }

    private void buildIndex(File dir, String title, Collection<? extends MavenArtifact> versions, String permalink)
            throws IOException {
        List<MavenArtifact> list = new ArrayList<MavenArtifact>(versions);
        Collections.sort(list, new Comparator<MavenArtifact>() {
            public int compare(MavenArtifact o1, MavenArtifact o2) {
                return -o1.getVersion().compareTo(o2.getVersion());
            }
        });

        IndexHtmlBuilder index = new IndexHtmlBuilder(dir, title);
        index.add(permalink, "permalink to the latest");
        for (MavenArtifact a : list)
            index.add(a);
        index.close();
    }

    /**
     * Creates a symlink.
     */
    private void ln(String from, File to) throws InterruptedException, IOException {
        to.getParentFile().mkdirs();

        ProcessBuilder pb = new ProcessBuilder();
        pb.command("ln", "-sf", from, to.getAbsolutePath());
        if (pb.start().waitFor() != 0)
            throw new IOException("ln failed");
    }

    /**
     * Identify the latest core, populates the htaccess redirect file, optionally download the core wars and build the index.html
     * @return the JSON for the core Jenkins
     */
    protected JSONObject buildCore(MavenRepository repository, PrintWriter redirect) throws Exception {
        TreeMap<VersionNumber, HudsonWar> wars = repository.getHudsonWar();
        if (wars.isEmpty())
            return null;

        HudsonWar latest = wars.get(wars.firstKey());
        JSONObject core = latest.toJSON("core");
        System.out.println("core\n=> " + core);

        redirect.printf("Redirect 302 /latest/jenkins.war %s\n", latest.getURL().getPath());
        redirect.printf(
                "Redirect 302 /latest/debian/jenkins.deb http://pkg.jenkins-ci.org/debian/binary/jenkins_%s_all.deb\n",
                latest.getVersion());
        redirect.printf(
                "Redirect 302 /latest/redhat/jenkins.rpm http://pkg.jenkins-ci.org/redhat/RPMS/noarch/jenkins-%s-1.1.noarch.rpm\n",
                latest.getVersion());
        redirect.printf(
                "Redirect 302 /latest/opensuse/jenkins.rpm http://pkg.jenkins-ci.org/opensuse/RPMS/noarch/jenkins-%s-1.1.noarch.rpm\n",
                latest.getVersion());

        if (latestCoreTxt != null)
            writeToFile(latest.getVersion().toString(), latestCoreTxt);

        if (download != null) {
            // build the download server layout
            for (HudsonWar w : wars.values()) {
                stage(w, new File(download, "war/" + w.version + "/" + w.getFileName()));
            }
        }

        if (www != null)
            buildIndex(new File(www, "download/war/"), "jenkins.war", wars.values(), "/latest/jenkins.war");

        return core;
    }

    static {
        addProvider(new BouncyCastleProvider());
    }
}