/*
* The MIT License
*
* Copyright (c) 2009-2010, Sun Microsystems, Inc., CloudBees, 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 jenkins.plugins.nodejs.tools;
import java.io.File;
import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.net.URLConnection;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import java.util.TreeSet;
import java.util.concurrent.TimeUnit;
import javax.annotation.Nonnull;
import org.apache.commons.lang.StringUtils;
import org.kohsuke.stapler.DataBoundConstructor;
import com.google.common.base.Predicate;
import com.google.common.collect.Collections2;
import hudson.EnvVars;
import hudson.Extension;
import hudson.FilePath;
import hudson.Functions;
import hudson.Launcher;
import hudson.Launcher.ProcStarter;
import hudson.ProxyConfiguration;
import hudson.Util;
import hudson.model.Node;
import hudson.model.TaskListener;
import hudson.remoting.VirtualChannel;
import hudson.tools.DownloadFromUrlInstaller;
import hudson.tools.ToolInstallation;
import hudson.util.ArgumentListBuilder;
import hudson.util.Secret;
import jenkins.MasterToSlaveFileCallable;
import jenkins.plugins.nodejs.Messages;
import jenkins.plugins.nodejs.NodeJSConstants;
import jenkins.plugins.tools.Installables;
/**
* Automatic NodeJS installer from nodejs.org
*
* @author Frédéric Camblor
* @author Nikolas Falco
*
* @since 0.2
*/
public class NodeJSInstaller extends DownloadFromUrlInstaller {
public static final String NPM_PACKAGES_RECORD_FILENAME = ".npmPackages";
/**
* Define the elapse time before perform a new npm install for defined
* global packages.
*/
public static final int DEFAULT_NPM_PACKAGES_REFRESH_HOURS = 72;
private final String npmPackages;
private final Long npmPackagesRefreshHours;
private Platform platform;
private CPU cpu;
@DataBoundConstructor
public NodeJSInstaller(String id, String npmPackages, long npmPackagesRefreshHours) {
super(id);
this.npmPackages = Util.fixEmptyAndTrim(npmPackages);
this.npmPackagesRefreshHours = npmPackagesRefreshHours;
}
@Override
public Installable getInstallable() throws IOException {
Installable installable = super.getInstallable();
if(installable==null) {
return null;
}
// Cloning the installable since we're going to update its url (not cloning it wouldn't be threadsafe)
installable = Installables.clone(installable);
InstallerPathResolver installerPathResolver = InstallerPathResolver.Factory.findResolverFor(installable);
String relativeDownloadPath = installerPathResolver.resolvePathFor(installable.id, platform, cpu);
installable.url += relativeDownloadPath;
return installable;
}
// Overriden performInstallation() in order to provide a custom
// url (installable.url should be platform+cpu dependant)
// + pullUp directory impl should differ from DownloadFromUrlInstaller
// implementation
@Override
public FilePath performInstallation(ToolInstallation tool, Node node, TaskListener log) throws IOException, InterruptedException {
this.platform = getPlatform(node);
this.cpu = getCPU(node);
FilePath expected;
Installable installable = getInstallable();
if (installable == null || !installable.url.toLowerCase(Locale.ENGLISH).endsWith("msi")) {
expected = super.performInstallation(tool, node, log);
} else {
expected = preferredLocation(tool, node);
if (!isUpToDate(expected, installable)) {
if (installIfNecessaryMSI(expected, new URL(installable.url), log, "Installing " + installable.url + " to " + expected + " on " + node.getDisplayName())) {
expected.child(".timestamp").delete(); // we don't use the timestamp
FilePath base = findPullUpDirectory(expected);
if (base != null && base != expected)
base.moveAllChildrenTo(expected);
// leave a record for the next up-to-date check
expected.child(".installedFrom").write(installable.url, "UTF-8");
}
}
}
refreshGlobalPackages(node, log, expected);
return expected;
}
private CPU getCPU(Node node) throws IOException, InterruptedException {
return CPU.of(node);
}
private Platform getPlatform(Node node) throws DetectionFailedException {
return Platform.of(node);
}
/*
* Installing npm packages if needed
*/
protected void refreshGlobalPackages(Node node, TaskListener log, FilePath expected) throws IOException, InterruptedException {
String globalPackages = getNpmPackages();
if (StringUtils.isNotBlank(globalPackages)) { // JENKINS-41876
boolean skipNpmPackageInstallation = areNpmPackagesUpToDate(expected, globalPackages, getNpmPackagesRefreshHours());
if (!skipNpmPackageInstallation) {
expected.child(NPM_PACKAGES_RECORD_FILENAME).delete();
ArgumentListBuilder npmScriptArgs = new ArgumentListBuilder();
if (platform == Platform.WINDOWS) {
npmScriptArgs.add("cmd");
npmScriptArgs.add("/c");
}
FilePath binFolder = expected.child(platform.binFolder);
FilePath npmExe = binFolder.child(platform.npmFileName);
npmScriptArgs.add(npmExe);
npmScriptArgs.add("install");
npmScriptArgs.add("-g");
for (String packageName : globalPackages.split("\\s")) {
npmScriptArgs.add(packageName);
}
EnvVars env = new EnvVars();
env.put(NodeJSConstants.ENVVAR_NODEJS_PATH, binFolder.getRemote());
try {
buildProxyEnvVars(env, log);
} catch (URISyntaxException e) {
log.error("Wrong proxy URL: " + e.getMessage());
}
hudson.Launcher launcher = node.createLauncher(log);
int returnCode = launcher.launch().envs(env).cmds(npmScriptArgs).stdout(log).join();
if (returnCode == 0) {
// leave a record for the next up-to-date check
expected.child(NPM_PACKAGES_RECORD_FILENAME).write(globalPackages, "UTF-8");
expected.child(NPM_PACKAGES_RECORD_FILENAME).act(new ChmodRecAPlusX());
}
}
}
}
private void buildProxyEnvVars(EnvVars env, TaskListener log) throws IOException, URISyntaxException {
ProxyConfiguration proxycfg = ProxyConfiguration.load();
if (proxycfg == null) {
// no proxy configured
return;
}
String userInfo = proxycfg.getUserName() != null ? proxycfg.getUserName() : null;
// append password only if userName if is defined
if (userInfo != null && proxycfg.getEncryptedPassword() != null) {
userInfo += ":" + Secret.decrypt(proxycfg.getEncryptedPassword());
}
String proxyURL = new URI("http", userInfo, proxycfg.name, proxycfg.port, null, null, null).toString();
// refer to https://docs.npmjs.com/misc/config#https-proxy
env.put("HTTP_PROXY", proxyURL);
env.put("HTTPS_PROXY", proxyURL);
String noProxyHosts = proxycfg.noProxyHost;
if (noProxyHosts != null) {
if (noProxyHosts.contains("*")) {
log.getLogger().println("INFO: npm doesn't support wild card in no_proxy configuration");
}
// refer to https://github.com/npm/npm/issues/7168
env.put("NO_PROXY", noProxyHosts.replaceAll("(\r?\n)+", ","));
}
}
public static boolean areNpmPackagesUpToDate(FilePath expected, String npmPackages, long npmPackagesRefreshHours) throws IOException, InterruptedException {
FilePath marker = expected.child(NPM_PACKAGES_RECORD_FILENAME);
return marker.exists() && marker.readToString().equals(npmPackages) && System.currentTimeMillis() < marker.lastModified()+ TimeUnit.HOURS.toMillis(npmPackagesRefreshHours);
}
private boolean installIfNecessaryMSI(FilePath expected, URL archive, TaskListener listener, String message) throws IOException, InterruptedException {
try {
URLConnection con;
try {
con = ProxyConfiguration.open(archive);
con.connect();
} catch (IOException x) {
if (expected.exists()) {
// Cannot connect now, so assume whatever was last unpacked is still OK.
if (listener != null) {
listener.getLogger().println("Skipping installation of " + archive + " to " + expected.getRemote() + ": " + x);
}
return false;
} else {
throw x;
}
}
long sourceTimestamp = con.getLastModified();
FilePath timestamp = expected.child(".timestamp");
if (expected.exists()) {
if (timestamp.exists() && sourceTimestamp == timestamp.lastModified()) {
return false; // already up to date
}
expected.deleteContents();
} else {
expected.mkdirs();
}
if (listener != null) {
listener.getLogger().println(message);
}
FilePath temp = expected.createTempDir("_temp", "");
FilePath msi = temp.child("nodejs.msi");
msi.copyFrom(archive);
try {
Launcher launch = temp.createLauncher(listener);
ProcStarter starter = launch.launch().cmds(new File("cmd"), "/c", "for %A in (.) do msiexec TARGETDIR=%~sA /a "+ temp.getName() + "\\nodejs.msi /qn /L* " + temp.getName() + "\\log.txt");
starter=starter.pwd(expected);
int exitCode=starter.join();
if (exitCode != 0) {
throw new IOException("msiexec failed. exit code: " + exitCode + " Please see the log file " + temp.child("log.txt").getRemote() + " for more informations.", null);
}
if (listener != null) {
listener.getLogger().println("msi install complete");
}
// remove temporary folder
temp.deleteRecursive();
// remove the double msi file in expected folder
FilePath duplicatedMSI = expected.child("nodejs.msi");
if (duplicatedMSI.exists()) {
duplicatedMSI.delete();
}
} catch (IOException e) {
throw new IOException("Failed to install "+ archive, e);
}
timestamp.touch(sourceTimestamp);
return true;
} catch (IOException e) {
throw new IOException("Failed to install " + archive + " to " + expected.getRemote(), e);
}
}
// update code from ZipExtractionInstaller
static class /*ZipExtractionInstaller*/ChmodRecAPlusX extends MasterToSlaveFileCallable<Void> {
private static final long serialVersionUID = 1L;
@Override
public Void invoke(File d, VirtualChannel channel) throws IOException {
if(!Functions.isWindows())
process(d);
return null;
}
private void process(File f) {
if (f.isFile()) {
f.setExecutable(true, false);
} else {
File[] kids = f.listFiles();
if (kids != null) {
for (File kid : kids) {
process(kid);
}
}
}
}
}
public String getNpmPackages() {
return npmPackages;
}
public Long getNpmPackagesRefreshHours() {
return npmPackagesRefreshHours;
}
@Extension
public static final class DescriptorImpl extends DownloadFromUrlInstaller.DescriptorImpl<NodeJSInstaller> { // NOSONAR
@Override
public String getDisplayName() {
return Messages.NodeJSInstaller_DescriptorImpl_displayName();
}
@Nonnull
@Override
public List<? extends Installable> getInstallables() throws IOException {
// Filtering non blacklisted installables + sorting installables by version number
Collection<? extends Installable> filteredInstallables = Collections2.filter(super.getInstallables(),
new Predicate<Installable>() {
@Override
public boolean apply(Installable input) {
return !InstallerPathResolver.Factory.isVersionBlacklisted(input.id);
}
});
TreeSet<Installable> sortedInstallables = new TreeSet<>(new Comparator<Installable>() {
@Override
public int compare(Installable o1, Installable o2) {
return NodeJSVersion.parseVersion(o1.id).compareTo(NodeJSVersion.parseVersion(o2.id)) * -1;
}
});
sortedInstallables.addAll(filteredInstallables);
return new ArrayList<>(sortedInstallables);
}
@Override
public String getId() {
// For backward compatibility
return "hudson.plugins.nodejs.tools.NodeJSInstaller";
}
@Override
public boolean isApplicable(Class<? extends ToolInstallation> toolType) {
return toolType == NodeJSInstallation.class;
}
}
}