/** * © Copyright 2015 Hewlett Packard Enterprise Development LP * Copyright (c) ActiveState 2014 - ALL RIGHTS RESERVED. */ package com.hpe.cloudfoundryjenkins; import com.cloudbees.plugins.credentials.CredentialsMatchers; import com.cloudbees.plugins.credentials.CredentialsProvider; import com.cloudbees.plugins.credentials.common.StandardListBoxModel; import com.cloudbees.plugins.credentials.common.StandardUsernameCredentials; import com.cloudbees.plugins.credentials.common.StandardUsernamePasswordCredentials; import com.cloudbees.plugins.credentials.domains.URIRequirementBuilder; import hudson.Extension; import hudson.FilePath; import hudson.Launcher; import hudson.ProxyConfiguration; import hudson.model.*; import hudson.security.ACL; import hudson.tasks.BuildStepDescriptor; import hudson.tasks.BuildStepMonitor; import hudson.tasks.Publisher; import hudson.tasks.Recorder; import hudson.util.FormValidation; import hudson.util.ListBoxModel; import hudson.util.Secret; import jenkins.model.Jenkins; import net.lingala.zip4j.core.ZipFile; import net.lingala.zip4j.exception.ZipException; import org.apache.tomcat.util.http.fileupload.FileUtils; import org.cloudfoundry.client.lib.*; import org.cloudfoundry.client.lib.domain.*; import org.cloudfoundry.client.lib.org.springframework.web.client.ResourceAccessException; import org.jenkinsci.plugins.tokenmacro.MacroEvaluationException; import org.kohsuke.stapler.AncestorInPath; import org.kohsuke.stapler.DataBoundConstructor; import org.kohsuke.stapler.QueryParameter; import javax.net.ssl.SSLPeerUnverifiedException; import java.io.*; import java.net.MalformedURLException; import java.net.URL; import java.net.UnknownHostException; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.regex.Pattern; public class CloudFoundryPushPublisher extends Recorder { private static final String DEFAULT_MANIFEST_PATH = "manifest.yml"; private static final int DEFAULT_PLUGIN_TIMEOUT = 120; public String target; public String organization; public String cloudSpace; public String credentialsId; public boolean selfSigned; public boolean resetIfExists; public int pluginTimeout; public List<Service> servicesToCreate; public ManifestChoice manifestChoice; private List<String> appURIs = new ArrayList<String>(); /** * The constructor is databound from the Jenkins config page, which is defined in config.jelly. */ @DataBoundConstructor public CloudFoundryPushPublisher(String target, String organization, String cloudSpace, String credentialsId, boolean selfSigned, boolean resetIfExists, int pluginTimeout, List<Service> servicesToCreate, ManifestChoice manifestChoice) { this.target = target; this.organization = organization; this.cloudSpace = cloudSpace; this.credentialsId = credentialsId; this.selfSigned = selfSigned; this.resetIfExists = resetIfExists; if (pluginTimeout == 0) { this.pluginTimeout = DEFAULT_PLUGIN_TIMEOUT; } else { this.pluginTimeout = pluginTimeout; } if (servicesToCreate == null) { this.servicesToCreate = new ArrayList<Service>(); } else { this.servicesToCreate = servicesToCreate; } if (manifestChoice == null) { this.manifestChoice = ManifestChoice.defaultManifestFileConfig(); } else { this.manifestChoice = manifestChoice; } } /** * This is the main method, which gets called when the plugin must run as part of a build. */ @Override public boolean perform(AbstractBuild build, Launcher launcher, BuildListener listener) { // We don't want to push if the build failed if (build.getResult().isWorseThan(Result.SUCCESS)) return true; listener.getLogger().println("Cloud Foundry Plugin:"); try { String jenkinsBuildName = build.getProject().getDisplayName(); URL targetUrl = new URL(target); List<StandardUsernamePasswordCredentials> standardCredentials = CredentialsProvider.lookupCredentials( StandardUsernamePasswordCredentials.class, build.getProject(), ACL.SYSTEM, URIRequirementBuilder.fromUri(target).build()); StandardUsernamePasswordCredentials credentials = CredentialsMatchers.firstOrNull(standardCredentials, CredentialsMatchers.withId(credentialsId)); if (credentials == null) { listener.getLogger().println("ERROR: No credentials have been given."); return false; } CloudCredentials cloudCredentials = new CloudCredentials(credentials.getUsername(), Secret.toString(credentials.getPassword())); HttpProxyConfiguration proxyConfig = buildProxyConfiguration(targetUrl); CloudFoundryClient client = new CloudFoundryClient(cloudCredentials, targetUrl, organization, cloudSpace, proxyConfig, selfSigned); client.login(); String domain = client.getDefaultDomain().getName(); // Create services before push List<CloudService> currentServicesList = client.getServices(); List<String> currentServicesNames = new ArrayList<String>(); for (CloudService currentService : currentServicesList) { currentServicesNames.add(currentService.getName()); } for (Service service : servicesToCreate) { boolean createService = true; if (currentServicesNames.contains(service.name)) { if (service.resetService) { listener.getLogger().println("Service " + service.name + " already exists, resetting."); client.deleteService(service.name); listener.getLogger().println("Service deleted."); } else { createService = false; listener.getLogger().println("Service " + service.name + " already exists, skipping creation."); } } if (createService) { listener.getLogger().println("Creating service " + service.name); CloudService cloudService = new CloudService(); cloudService.setName(service.name); cloudService.setLabel(service.type); cloudService.setPlan(service.plan); client.createService(cloudService); } } // Get all deployment info List<DeploymentInfo> allDeploymentInfo = new ArrayList<DeploymentInfo>(); if (manifestChoice.value.equals("manifestFile")) { // Read manifest file FilePath manifestFilePath = new FilePath(build.getWorkspace(), manifestChoice.manifestFile); ManifestReader manifestReader = new ManifestReader(manifestFilePath); List<Map<String, Object>> appList = manifestReader.getApplicationList(); for (Map<String, Object> appInfo : appList) { allDeploymentInfo.add( new DeploymentInfo(build, listener, listener.getLogger(), appInfo, jenkinsBuildName, domain, manifestChoice.manifestFile)); } } else { // Read Jenkins configuration allDeploymentInfo.add( new DeploymentInfo(build, listener, listener.getLogger(), manifestChoice, jenkinsBuildName, domain)); } boolean success = true; for (DeploymentInfo deploymentInfo : allDeploymentInfo) { boolean lastSuccess = processOneApp(client, deploymentInfo, build, listener); // If an app fails, the build status is failure, but we should still try pushing them success = success && lastSuccess; } return success; } catch (MalformedURLException e) { listener.getLogger().println("ERROR: The target URL is not valid: " + e.getMessage()); return false; } catch (ResourceAccessException e) { if (e.getCause() instanceof UnknownHostException) { listener.getLogger().println("ERROR: Unknown host: " + e.getMessage()); } else if (e.getCause() instanceof SSLPeerUnverifiedException) { listener.getLogger().println("ERROR: Certificate is not verified: " + e.getMessage()); } else { listener.getLogger().println("ERROR: Unknown ResourceAccessException: " + e.getMessage()); } return false; } catch (CloudFoundryException e) { if (e.getMessage().equals("403 Access token denied.")) { listener.getLogger().println("ERROR: Wrong username or password: " + e.getMessage()); } else { listener.getLogger().println("ERROR: Unknown CloudFoundryException: " + e.getMessage()); listener.getLogger().println("ERROR: Cloud Foundry error code: " + e.getCloudFoundryErrorCode()); if (e.getDescription() != null) { listener.getLogger().println("ERROR: " + e.getDescription()); } e.printStackTrace(listener.getLogger()); } return false; } catch (CloudOperationException e) { listener.getLogger().println("ERROR: Target returned an error: " + e.getMessage()); return false; } catch (ManifestParsingException e) { listener.getLogger().println("ERROR: Could not parse manifest: " + e.getMessage()); return false; } catch (MacroEvaluationException e) { listener.getLogger().println("ERROR: Could not parse token macro: " + e.getMessage()); return false; } catch (IOException e) { listener.getLogger().println("ERROR: IOException: " + e.getMessage()); return false; } catch (InterruptedException e) { listener.getLogger().println("ERROR: InterruptedException: " + e.getMessage()); return false; } catch (Exception e) { e.printStackTrace(listener.getLogger()); return false; } } private boolean processOneApp(CloudFoundryClient client, DeploymentInfo deploymentInfo, AbstractBuild build, BuildListener listener) throws IOException, InterruptedException { try { String appName = deploymentInfo.getAppName(); String appURI = "https://" + deploymentInfo.getHostname() + "." + deploymentInfo.getDomain(); addToAppURIs(appURI); listener.getLogger().println("Pushing " + appName + " app to " + target); // Create app if it doesn't already exist, or if resetIfExists parameter is true boolean createdNewApp = createApplicationIfNeeded(client, listener, deploymentInfo, appURI); // Unbind all routes if no-route parameter is set if (deploymentInfo.isNoRoute()) { client.updateApplicationUris(appName, new ArrayList<String>()); } // Add environment variables if (!deploymentInfo.getEnvVars().isEmpty()) { Map<String, Object> appEnvs = client.getApplicationEnvironment(appName); Map<String, String> newEnvs = new HashMap<String, String>(); // Unavoidable cast warning newEnvs.putAll((Map<String, String>) appEnvs.get("environment_json")); newEnvs.putAll(deploymentInfo.getEnvVars()); client.updateApplicationEnv(appName, newEnvs); } // Change number of instances if (deploymentInfo.getInstances() > 1) { client.updateApplicationInstances(appName, deploymentInfo.getInstances()); } // Push files listener.getLogger().println("Pushing app bits."); pushAppBits(build, listener, deploymentInfo, client); // Start or restart application StartingInfo startingInfo; if (createdNewApp) { listener.getLogger().println("Starting application."); startingInfo = client.startApplication(appName); } else { listener.getLogger().println("Restarting application."); startingInfo = client.restartApplication(appName); } // Start printing the staging logs printStagingLogs(client, listener, startingInfo, appName); CloudApplication app = client.getApplication(appName); // Keep checking to see if the app is running int running = 0; int totalInstances = 0; for (int tries = 0; tries < pluginTimeout; tries++) { running = 0; InstancesInfo instancesInfo = client.getApplicationInstances(app); if (instancesInfo != null) { List<InstanceInfo> listInstances = instancesInfo.getInstances(); totalInstances = listInstances.size(); for (InstanceInfo instance : listInstances) { if (instance.getState() == InstanceState.RUNNING) { running++; } } if (running == totalInstances && totalInstances > 0) { break; } } Thread.sleep(1000); } String instanceGrammar = "instances"; if (running == 1) instanceGrammar = "instance"; listener.getLogger().println(running + " " + instanceGrammar + " running out of " + totalInstances); if (running > 0) { if (running != totalInstances) { listener.getLogger().println("WARNING: Some instances of the application are not running."); } if (deploymentInfo.isNoRoute()) { listener.getLogger().println("Application is now running. (No route)"); } else { listener.getLogger().println("Application is now running at " + appURI); } listener.getLogger().println("Cloud Foundry push successful."); return true; } else { listener.getLogger().println( "ERROR: The application failed to start after " + pluginTimeout + " seconds."); listener.getLogger().println("Cloud Foundry push failed."); return false; } } catch (CloudFoundryException e) { listener.getLogger().println("ERROR: Unknown CloudFoundryException: " + e.getMessage()); listener.getLogger().println("ERROR: Cloud Foundry error code: " + e.getCloudFoundryErrorCode()); if (e.getDescription() != null) { listener.getLogger().println("ERROR: " + e.getDescription()); } e.printStackTrace(listener.getLogger()); return false; } catch (FileNotFoundException e) { listener.getLogger().println("ERROR: Could not find file: " + e.getMessage()); return false; } catch (ZipException e) { listener.getLogger().println("ERROR: ZipException: " + e.getMessage()); return false; } catch (IllegalArgumentException e) { listener.getLogger().println("ERROR: IllegalArgumentException: " + e.getMessage()); return false; } } private boolean createApplicationIfNeeded(CloudFoundryClient client, BuildListener listener, DeploymentInfo deploymentInfo, String appURI) { // Check if app already exists List<CloudApplication> existingApps = client.getApplications(); boolean createNewApp = true; for (CloudApplication app : existingApps) { if (app.getName().equals(deploymentInfo.getAppName())) { if (resetIfExists) { listener.getLogger().println("App already exists, resetting."); client.deleteApplication(deploymentInfo.getAppName()); listener.getLogger().println("App deleted."); } else { createNewApp = false; listener.getLogger().println("App already exists, skipping creation."); } break; } } // Create app if it doesn't exist if (createNewApp) { listener.getLogger().println("Creating new app."); String stack = deploymentInfo.getStack(); if (stack != null && client.getStack(stack) == null) { throw new IllegalArgumentException("Stack " + stack + " does not exist on the target."); } Staging staging = new Staging(deploymentInfo.getCommand(), deploymentInfo.getBuildpack(), deploymentInfo.getStack(), deploymentInfo.getTimeout()); List<String> uris = new ArrayList<String>(); // Pass an empty List as the uri list if no-route is set if (!deploymentInfo.isNoRoute()) { uris.add(appURI); } List<String> services = deploymentInfo.getServicesNames(); client.createApplication(deploymentInfo.getAppName(), staging, deploymentInfo.getMemory(), uris, services); } return createNewApp; } private void pushAppBits(AbstractBuild build, BuildListener listener, DeploymentInfo deploymentInfo, CloudFoundryClient client) throws IOException, InterruptedException, ZipException { FilePath appPath = new FilePath(build.getWorkspace(), deploymentInfo.getAppPath()); if (appPath.getChannel() != Jenkins.MasterComputer.localChannel) { if (appPath.isDirectory()) { // The build is distributed, and a directory // We need to make a copy of the target directory on the master File tempAppFile = File.createTempFile("appFile", null); // This is on the master OutputStream outputStream = new FileOutputStream(tempAppFile); appPath.zip(outputStream); // We now have a zip file on the master, extract it into a directory ZipFile appZipFile = new ZipFile(tempAppFile); File tempOutputDirectory = new File(tempAppFile.getAbsolutePath().split("\\.")[0]); appZipFile.extractAll(tempOutputDirectory.getAbsolutePath()); // appPath.zip() creates a top level directory that we want to remove File[] listFiles = tempOutputDirectory.listFiles(); if (listFiles != null && listFiles.length == 1) { tempOutputDirectory = listFiles[0]; } else { // This should never happen because appPath.zip() always makes a directory throw new IllegalStateException("Unzipped output directory was empty."); } // We can now use tempOutputDirectory which is a copy of the target directory but on master client.uploadApplication(deploymentInfo.getAppName(), tempOutputDirectory); // Delete temporary files boolean deleted = tempAppFile.delete(); try { FileUtils.deleteDirectory(tempOutputDirectory); } catch (IOException e) { deleted = false; } if (!deleted) { listener.getLogger().println("WARNING: Temporary files were not deleted successfully."); } } else { // If the target path is a single file, we can just use an InputStream // The CF client will make a temp file on the master from the InputStream client.uploadApplication(deploymentInfo.getAppName(), appPath.getName(), appPath.read()); } } else { // If the build is not distributed, we can convert the FilePath to a File without problems File targetFile = new File(appPath.toURI()); client.uploadApplication(deploymentInfo.getAppName(), targetFile); } } private void printStagingLogs(CloudFoundryClient client, BuildListener listener, StartingInfo startingInfo, String appName) { // First, try streamLogs() try { JenkinsApplicationLogListener logListener = new JenkinsApplicationLogListener(listener); client.streamLogs(appName, logListener); } catch (Exception e) { // In case of failure, try getStagingLogs() listener.getLogger().println("WARNING: Exception occurred trying to get staging logs via websocket. " + "Switching to alternate method."); int offset = 0; String stagingLogs = client.getStagingLogs(startingInfo, offset); if (stagingLogs == null) { listener.getLogger().println("WARNING: Could not get staging logs with alternate method. " + "Cannot display staging logs."); } else { while (stagingLogs != null) { listener.getLogger().println(stagingLogs); offset += stagingLogs.length(); stagingLogs = client.getStagingLogs(startingInfo, offset); } } } } private static HttpProxyConfiguration buildProxyConfiguration(URL targetURL) { ProxyConfiguration proxyConfig = Hudson.getInstance().proxy; if (proxyConfig == null) { return null; } String host = targetURL.getHost(); for (Pattern p : proxyConfig.getNoProxyHostPatterns()) { if (p.matcher(host).matches()) { return null; } } return new HttpProxyConfiguration(proxyConfig.name, proxyConfig.port); } public BuildStepMonitor getRequiredMonitorService() { return BuildStepMonitor.NONE; } public List<String> getAppURIs() { return appURIs; } public void addToAppURIs(String appURI) { this.appURIs.add(appURI); } /** * This class contains the choice of using either a manifest file or the optional Jenkins configuration. * It also contains all the variables of either choice, which will be non-null only if their choice was selected. * It bothers me that a single class has these multiple uses, but everything is contained in the radioBlock tags * in config.jelly and must be databound to a single class. It doesn't seem like there is an alternative. */ public static class ManifestChoice { // This should only be either "manifestFile" or "jenkinsConfig" public final String value; // Variable of the choice "manifestFile". Will be null if 'value' is "jenkinsConfig". public final String manifestFile; // Variables of the choice "jenkinsConfig". Will all be null (or 0 or false) if 'value' is "manifestFile". public final String appName; public final int memory; public final String hostname; public final int instances; public final int timeout; public final boolean noRoute; public final String appPath; public final String buildpack; public final String stack; public final String command; public final String domain; public final List<EnvironmentVariable> envVars; public final List<ServiceName> servicesNames; @DataBoundConstructor public ManifestChoice(String value, String manifestFile, String appName, int memory, String hostname, int instances, int timeout, boolean noRoute, String appPath, String buildpack, String stack, String command, String domain, List<EnvironmentVariable> envVars, List<ServiceName> servicesNames) { if (value == null) { this.value = "manifestFile"; } else { this.value = value; } if (manifestFile == null || manifestFile.isEmpty()) { this.manifestFile = DEFAULT_MANIFEST_PATH; } else { this.manifestFile = manifestFile; } this.appName = appName; this.memory = memory; this.hostname = hostname; this.instances = instances; this.timeout = timeout; this.noRoute = noRoute; this.appPath = appPath; this.buildpack = buildpack; this.stack = stack; this.command = command; this.domain = domain; this.envVars = envVars; this.servicesNames = servicesNames; } /** * Constructs a ManifestChoice with the default settings for using a manifest file. * This is mostly for easier unit tests. */ public static ManifestChoice defaultManifestFileConfig() { return new ManifestChoice("manifestFile", DEFAULT_MANIFEST_PATH, null, 0, null, 0, 0, false, null, null, null, null, null, null, null); } } public static class EnvironmentVariable { public final String key; public final String value; @DataBoundConstructor public EnvironmentVariable(String key, String value) { this.key = key; this.value = value; } } // This class is for services to bind to the app. We only get the name of the service. public static class ServiceName { public final String name; @DataBoundConstructor public ServiceName(String name) { this.name = name; } } // This class is for services to create. We need name, type and plan for this. public static class Service { public final String name; public final String type; public final String plan; public final boolean resetService; @DataBoundConstructor public Service(String name, String type, String plan, boolean resetService) { this.name = name; this.type = type; this.plan = plan; this.resetService = resetService; } } @Extension public static final class DescriptorImpl extends BuildStepDescriptor<Publisher> { public static final int DEFAULT_MEMORY = 512; public static final int DEFAULT_INSTANCES = 1; public static final int DEFAULT_TIMEOUT = 60; public static final String DEFAULT_STACK = null; // null stack means it uses the default stack of the target @Override public boolean isApplicable(Class<? extends AbstractProject> jobType) { return true; } @Override public String getDisplayName() { return "Push to Cloud Foundry"; } /** * This method is called to populate the credentials list on the Jenkins config page. */ @SuppressWarnings("unused") public ListBoxModel doFillCredentialsIdItems(@AncestorInPath ItemGroup context, @QueryParameter("target") final String target) { StandardListBoxModel result = new StandardListBoxModel(); result.withEmptySelection(); result.withMatching(CredentialsMatchers.instanceOf(StandardUsernamePasswordCredentials.class), CredentialsProvider.lookupCredentials( StandardUsernameCredentials.class, context, ACL.SYSTEM, URIRequirementBuilder.fromUri(target).build() ) ); return result; } /** * This method is called when the "Test Connection" button is clicked on the Jenkins config page. */ @SuppressWarnings("unused") public FormValidation doTestConnection(@AncestorInPath ItemGroup context, @QueryParameter("target") final String target, @QueryParameter("credentialsId") final String credentialsId, @QueryParameter("organization") final String organization, @QueryParameter("cloudSpace") final String cloudSpace, @QueryParameter("selfSigned") final boolean selfSigned) { try { URL targetUrl = new URL(target); List<StandardUsernamePasswordCredentials> standardCredentials = CredentialsProvider.lookupCredentials( StandardUsernamePasswordCredentials.class, context, ACL.SYSTEM, URIRequirementBuilder.fromUri(target).build()); StandardUsernamePasswordCredentials credentials = CredentialsMatchers.firstOrNull(standardCredentials, CredentialsMatchers.withId(credentialsId)); CloudCredentials cloudCredentials = new CloudCredentials(credentials.getUsername(), Secret.toString(credentials.getPassword())); HttpProxyConfiguration proxyConfig = buildProxyConfiguration(targetUrl); CloudFoundryClient client = new CloudFoundryClient(cloudCredentials, targetUrl, organization, cloudSpace, proxyConfig, selfSigned); client.login(); client.getCloudInfo(); if (targetUrl.getHost().startsWith("api.")) { return FormValidation.okWithMarkup("<b>Connection successful!</b>"); } else { return FormValidation.warning( "Connection successful, but your target's hostname does not start with \"api.\".\n" + "Make sure it is the real API endpoint and not a redirection, " + "or it may cause some problems."); } } catch (MalformedURLException e) { return FormValidation.error("Malformed target URL"); } catch (ResourceAccessException e) { if (e.getCause() instanceof UnknownHostException) { return FormValidation.error("Unknown host"); } else if (e.getCause() instanceof SSLPeerUnverifiedException) { return FormValidation.error("Target's certificate is not verified " + "(Add it to Java's keystore, or check the \"Allow self-signed\" box)"); } else { return FormValidation.error(e, "Unknown ResourceAccessException"); } } catch (CloudFoundryException e) { if (e.getMessage().equals("404 Not Found")) { return FormValidation.error("Could not find CF API info (Did you forget to add \"api.\"?)"); } else if (e.getMessage().equals("403 Access token denied.")) { return FormValidation.error("Wrong username or password"); } else { return FormValidation.error(e, "Unknown CloudFoundryException"); } } catch (IllegalArgumentException e) { if (e.getMessage().contains("No matching organization and space found")) { return FormValidation.error("Could not find Organization or Space"); } else { return FormValidation.error(e, "Unknown IllegalArgumentException"); } } catch (Exception e) { return FormValidation.error(e, "Unknown Exception"); } } @SuppressWarnings("unused") public FormValidation doCheckTarget(@QueryParameter String value) { if (!value.isEmpty()) { try { URL targetUrl = new URL(value); } catch (MalformedURLException e) { return FormValidation.error("Malformed URL"); } } return FormValidation.validateRequired(value); } @SuppressWarnings("unused") public FormValidation doCheckCredentialsId(@QueryParameter String value) { return FormValidation.validateRequired(value); } @SuppressWarnings("unused") public FormValidation doCheckOrganization(@QueryParameter String value) { return FormValidation.validateRequired(value); } @SuppressWarnings("unused") public FormValidation doCheckCloudSpace(@QueryParameter String value) { return FormValidation.validateRequired(value); } @SuppressWarnings("unused") public FormValidation doCheckPluginTimeout(@QueryParameter String value) { return FormValidation.validatePositiveInteger(value); } @SuppressWarnings("unused") public FormValidation doCheckMemory(@QueryParameter String value) { return FormValidation.validatePositiveInteger(value); } @SuppressWarnings("unused") public FormValidation doCheckInstances(@QueryParameter String value) { return FormValidation.validatePositiveInteger(value); } @SuppressWarnings("unused") public FormValidation doCheckTimeout(@QueryParameter String value) { return FormValidation.validatePositiveInteger(value); } @SuppressWarnings("unused") public FormValidation doCheckAppName(@QueryParameter String value) { return FormValidation.validateRequired(value); } @SuppressWarnings("unused") public FormValidation doCheckHostname(@QueryParameter String value) { return FormValidation.validateRequired(value); } } /** * This method is called after a plugin upgrade, to convert an old configuration into a new one. * See: https://wiki.jenkins-ci.org/display/JENKINS/Hint+on+retaining+backward+compatibility */ @SuppressWarnings("unused") private Object readResolve() { if (servicesToCreate == null) { // Introduced in 1.4 this.servicesToCreate = new ArrayList<Service>(); } if (pluginTimeout == 0) { // Introduced in 1.5 this.pluginTimeout = DEFAULT_PLUGIN_TIMEOUT; } return this; } }