/** * Copyright 2013-2017 Linagora, Université Joseph Fourier, Floralis * * The present code is developed in the scope of the joint LINAGORA - * Université Joseph Fourier - Floralis research program and is designated * as a "Result" pursuant to the terms and conditions of the LINAGORA * - Université Joseph Fourier - Floralis research program. Each copyright * holder of Results enumerated here above fully & independently holds complete * ownership of the complete Intellectual Property rights applicable to the whole * of said Results, and may freely exploit it in any manner which does not infringe * the moral rights of the other copyright holders. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package net.roboconf.plugin.puppet.internal; import java.io.File; import java.io.IOException; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.Map.Entry; import java.util.Properties; import java.util.logging.Logger; import net.roboconf.core.model.beans.Import; import net.roboconf.core.model.beans.Instance; import net.roboconf.core.model.beans.Instance.InstanceStatus; import net.roboconf.core.model.helpers.InstanceHelpers; import net.roboconf.core.model.helpers.VariableHelpers; import net.roboconf.core.utils.ProgramUtils; import net.roboconf.core.utils.Utils; import net.roboconf.plugin.api.PluginException; import net.roboconf.plugin.api.PluginInterface; /** * The plug-in executes a Puppet manifests. * <p> * WARNING: when this plug-in is used on a local IaaS / host, * only the initialization will work. Other actions that require root * permissions will fail. They may work on a real IaaS because the agent * (which runs this plug-in) must be started with an "init.d" script. Thus, * it automatically inherits root permissions. * </p> * <p> * Modules will be installed automatically during the initialization. * Although there can be several manifests into the "manifests" directory, * only "init.pp" will be used. Other should be referenced through includes. * </p> * <p> * The best solution is to use the default template to mutualize actions. * Thus, start and stop can be achieved through a same script that will either * have the running state to RUNNING or to STOPPED. * </p> * <p> * The action is one of "deploy", "start", "stop", "undeploy" and "update".<br> * Let's take an example with the "start" action to understand the way this plug-in works. * </p> * <ul> * <li>The plug-in will load manifests/start.pp</li> * <li>If it is not found, it will try to load templates/start.pp.template</li> * <li>If it is not found, it will try to load templates/default.pp.template</li> * <li>If it is not found, the plug-in will do nothing</li> * </ul> * <p> * The default template is used to factorize actions. * </p> * * @author Noël - LIG * @author Vincent Zurczak - Linagora * @author Christophe Hamerling - Linagora */ public class PluginPuppet implements PluginInterface { public static final String PLUGIN_NAME = "puppet"; private static final String MANIFESTS_FOLDER = "manifests"; private final Logger logger = Logger.getLogger( getClass().getName()); String agentId; String applicationName; String scopedInstancePath; @Override public String getPluginName() { return PLUGIN_NAME; } @Override public void setNames( String applicationName, String scopedInstancePath ) { this.applicationName = applicationName; this.scopedInstancePath = scopedInstancePath; this.agentId = "'" + scopedInstancePath + "' agent"; } @Override public void initialize( Instance instance ) throws PluginException { this.logger.fine( this.agentId + " is initializing the plug-in for " + instance.getName()); try { installPuppetModules( instance ); } catch( Exception e ) { throw new PluginException( e ); } } @Override public void deploy( Instance instance ) throws PluginException { this.logger.fine( this.agentId + " is deploying instance " + instance.getName()); File instanceDirectory = InstanceHelpers.findInstanceDirectoryOnAgent( instance ); try { callPuppetScript( instance, "deploy", PuppetState.STOPPED, null, false, instanceDirectory ); } catch( Exception e ) { throw new PluginException( e ); } } @Override public void start( Instance instance ) throws PluginException { this.logger.fine( this.agentId + " is starting instance " + instance.getName()); File instanceDirectory = InstanceHelpers.findInstanceDirectoryOnAgent( instance ); try { callPuppetScript( instance, "start", PuppetState.RUNNING, null, false, instanceDirectory ); } catch( Exception e ) { throw new PluginException( e ); } } @Override public void update(Instance instance, Import importChanged, InstanceStatus statusChanged) throws PluginException { this.logger.fine( this.agentId + " is updating instance " + instance.getName()); File instanceDirectory = InstanceHelpers.findInstanceDirectoryOnAgent( instance ); try { callPuppetScript( instance, "update", PuppetState.UNDEF, importChanged, (statusChanged == InstanceStatus.DEPLOYED_STARTED), instanceDirectory ); } catch( Exception e ) { throw new PluginException( e ); } } @Override public void stop( Instance instance ) throws PluginException { this.logger.fine( this.agentId + " is stopping instance " + instance.getName()); File instanceDirectory = InstanceHelpers.findInstanceDirectoryOnAgent( instance ); try { callPuppetScript( instance, "stop", PuppetState.STOPPED, null, false, instanceDirectory ); } catch( Exception e ) { throw new PluginException( e ); } } @Override public void undeploy( Instance instance ) throws PluginException { this.logger.fine( this.agentId + " is undeploying instance " + instance.getName()); File instanceDirectory = InstanceHelpers.findInstanceDirectoryOnAgent( instance ); try { callPuppetScript( instance, "undeploy", PuppetState.UNDEF, null, false, instanceDirectory ); } catch( Exception e ) { throw new PluginException( e ); } } /** * Executes a Puppet command to install the required modules. * @param instance the instance * @throws IOException * @throws InterruptedException * @throws PluginException */ void installPuppetModules( Instance instance ) throws IOException, InterruptedException { // Load the modules names File instanceDirectory = InstanceHelpers.findInstanceDirectoryOnAgent( instance ); File modulesFile = new File( instanceDirectory, "modules.properties" ); if( ! modulesFile.exists()) return; Properties props = Utils.readPropertiesFile( modulesFile ); for( Map.Entry<Object,Object> entry : props.entrySet()) { List<String> commands = new ArrayList<> (); commands.add( "puppet" ); commands.add( "module" ); commands.add( "install" ); String value = entry.getValue().toString(); if( ! Utils.isEmptyOrWhitespaces( value )) { commands.add( "--version" ); commands.add( value ); } commands.add((String) entry.getKey()); commands.add( "--target-dir" ); commands.add( instanceDirectory.getAbsolutePath()); String[] params = commands.toArray( new String[ 0 ]); this.logger.fine( "Module installation: " + Arrays.toString( params )); int exitCode = ProgramUtils.executeCommand( this.logger, commands, null, null, this.applicationName, this.scopedInstancePath); if( exitCode != 0 ) throw new IOException( "Puppet modules could not be installed for " + instance + "." ); } } /** * Invokes Puppet to inject variables into the instance's manifests. * @param instance the instance * @param action the name of the action to run * @param puppetState a Puppet state * @param importChanged The import that changed (added or removed) upon update * @param importAdded true if the changed import is added, false if it is removed * @param instanceDirectory where to find instance files * @throws IOException if the puppet execution could not start * @throws InterruptedException if the Puppet execution was interrupted */ void callPuppetScript( Instance instance, String action, PuppetState puppetState, Import importChanged, boolean importAdded, File instanceDirectory ) throws IOException, InterruptedException { //this.logger.finer( "Instance directory for " + instance + " is " + instanceDirectory ); if( instance == null || instanceDirectory == null || ! instanceDirectory.isDirectory()) { this.logger.fine( "Ignoring null or invalid instance directory" ); return; } // Find the action to execute // If not found, try init.pp this.logger.info("Preparing the invocation of the script for " + action + " and instance " + instance.getName() + "."); File[] subFiles = instanceDirectory.listFiles(); if( subFiles == null ) subFiles = new File[ 0 ]; File moduleDirectory = null; for( File f : subFiles ) { if( f.isDirectory() && f.getName().toLowerCase().startsWith( "roboconf_" )) { moduleDirectory = f; break; } } if( moduleDirectory == null ) throw new IOException( "The module directory could not be found. The module to execute must begin with 'roboconf_'." ); // Look for action-specific manifest String clazz = moduleDirectory.getName() + "::" + action; File scriptFile = new File(moduleDirectory, MANIFESTS_FOLDER + "/" + action + ".pp"); // No action-specific manifest, try default init.pp if( ! scriptFile.exists()) { clazz = moduleDirectory.getName(); scriptFile = new File(moduleDirectory, MANIFESTS_FOLDER + "/init.pp"); } // Found either action-specific or default manifest if( scriptFile.exists()) { // Prepare the command and execute it List<String> commands = new ArrayList<> (); commands.add( "puppet" ); commands.add( "apply" ); commands.add( "--verbose" ); commands.add("--detailed-exitcodes"); String modpath = System.getenv("MODULEPATH"); if( modpath != null ) modpath += (modpath.endsWith(File.pathSeparator) ? "" : File.pathSeparator); else modpath = ""; modpath += instanceDirectory.getAbsolutePath(); commands.add( "--modulepath" ); commands.add(modpath); commands.add( "--execute" ); commands.add( generateCodeToExecute(clazz, instance, puppetState, importChanged, importAdded)); String[] params = commands.toArray( new String[ 0 ]); this.logger.fine( "Module installation: " + Arrays.toString( params )); // Execute Puppet. // Puppet normalized exit codes to provide feedback about the execution. // 0 or 2 => correct execution. // 4 => errors during execution. // 6 => changes were applied, but errors occurred too. int exitCode = ProgramUtils.executeCommand( this.logger, commands, null, null, this.applicationName, this.scopedInstancePath); switch( exitCode ) { case 0: case 2: this.logger.fine( "Puppet script properly completed with exit code " + exitCode + " (success codes are 2 and 0)." ); break; case 6: // FIXME: should we throw an exception? this.logger.warning( "Puppet script completed with changes and errors (exit code 6)." ); break; case 4: default: throw new IOException( "Puppet script execution failed (exit code " + exitCode + ")." ); } } else { this.logger.warning("Ignoring action " + action + ": no manifest provided, neither specific nor default init.pp."); } } /** * Generates the code to be injected by Puppet into the manifest. * @param instance the instance * @param puppetState the Puppet state * @param importChanged The import that changed (added or removed) upon update * @param importAdded true if the changed import is added, false if it is removed * @return a non-null string */ String generateCodeToExecute( String className, Instance instance, PuppetState puppetState, Import importChanged, boolean importAdded ) { // When executed by hand, the "apply" command would expect // this string to be returned to be between double quotes. // Example: "class{ 'roboconf_redis': ... }" // However, this does not work when executed from a Process builder. // The double quotes must be removed so that it works. StringBuilder sb = new StringBuilder(); sb.append( "class{'" ); sb.append( className ); sb.append( "': runningState => " ); sb.append( puppetState.toString().toLowerCase()); // Prepare the injection of variables into the Puppet receipt Map<String,String> exports = InstanceHelpers.findAllExportedVariables( instance ); String args = formatExportedVariables( exports ); String importedTypes = formatInstanceImports( instance ); if( ! Utils.isEmptyOrWhitespaces( args )) sb.append( ", " + args ); if( ! Utils.isEmptyOrWhitespaces( importedTypes )) sb.append( ", " + importedTypes ); if(importChanged != null) { String componentName = importChanged.getComponentName(); sb.append(", importDiff => {" + (importAdded ? "added => {" : "removed => {") + formatImport(importChanged) + "}, " + (importAdded ? "removed => undef" : "added => undef") + ", component => " + (componentName != null ? componentName : "undef") + "}"); } sb.append("}"); return sb.toString(); } /** * Returns a String representing all the exported variables and their value. * <p> * Must be that way:<br> * {@code varName1 => 'varValue1', varName2 => undef, varName3 => 'varValue3'} * </p> * <p> * It is assumed the prefix of the exported variable (component or facet name) * is not required. * </p> * <p> * As an example...<br> * Export "Redis.port = 4040" will generate "port => 4040".<br> * Export "Redis.port = null" will generate "port => undef". * </p> * * @param instanceExports the instance * @return a non-null string */ String formatExportedVariables( Map<String,String> instanceExports ) { StringBuilder sb = new StringBuilder(); boolean first = true; for( Entry<String,String> entry : instanceExports.entrySet()) { if( first ) first = false; else sb.append(", "); String vname = VariableHelpers.parseVariableName( entry.getKey()).getValue(); sb.append( vname.toLowerCase() ); sb.append( " => " ); if( Utils.isEmptyOrWhitespaces( entry.getValue())) sb.append( "undef" ); else sb.append( "'" + entry.getValue() + "'" ); } return sb.toString(); } /** * Returns a String representing all the imports and their values. * <p> * Must be that way: * <code> * { importTypeName => { 'importTypeName11' => { 'varName1' => 'varValue1', 'varName2' => 'varValue2' }, * 'importTypeName12' => { 'varName1' => 'varValue1', 'varName2' => 'varValue2' } }, $importTypeName2 => undef } * </code> * </p> * * @param instance the instance * @return a non-null string */ String formatInstanceImports( Instance instance ) { StringBuilder sb = new StringBuilder(); boolean first = true; for( String facetOrComponentName : VariableHelpers.findPrefixesForImportedVariables( instance )) { if( first ) first = false; else sb.append(", "); // Declare the first ImportedVar, // Eg: "$workers = ..." sb.append( facetOrComponentName.toLowerCase() ); sb.append( " => " ); Collection<Import> imports = instance.getImports().get( facetOrComponentName ); if( imports == null || imports.isEmpty()) { // No component has exported the variable this component expected. // put "undef". Example: "$workers = undef" sb.append("undef"); } else { // The component has received configurations from the others. // Eg: "$workers = { 'workers1' => {...} , 'workers2' => {...} }" sb.append( "{ " ); for( Iterator<Import> it = imports.iterator(); it.hasNext(); ) { sb.append( formatImport( it.next())); if( it.hasNext()) sb.append(", "); } sb.append( "}" ); } } return sb.toString(); } private String formatImport(Import imp) { StringBuilder sb = new StringBuilder(); sb.append( "'" ); sb.append(imp.getInstancePath()); sb.append( "' => { " ); sb.append( formatExportedVariables( imp.getExportedVars())); sb.append(" }"); return sb.toString(); } /** * The running states for Puppet. * @author Vincent Zurczak - Linagora */ public static enum PuppetState { RUNNING, STOPPED, UNDEF; @Override public String toString() { return super.toString().toLowerCase(); }; } }