/** * Copyright 2015-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.target.docker.internal; import java.io.BufferedInputStream; import java.io.File; import java.io.IOException; import java.io.InputStream; import java.lang.reflect.Method; import java.net.URISyntaxException; import java.net.URL; import java.net.URLConnection; import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.logging.Logger; import org.apache.commons.lang.WordUtils; import org.ops4j.pax.url.mvn.MavenResolver; import com.github.dockerjava.api.DockerClient; import com.github.dockerjava.api.command.CreateContainerCmd; import com.github.dockerjava.api.command.InspectContainerResponse; import com.github.dockerjava.api.command.InspectContainerResponse.ContainerState; import com.github.dockerjava.api.model.Capability; import com.github.dockerjava.api.model.Container; import com.github.dockerjava.api.model.Image; import com.github.dockerjava.core.DefaultDockerClientConfig; import com.github.dockerjava.core.DefaultDockerClientConfig.Builder; import com.github.dockerjava.core.DockerClientBuilder; import com.google.gson.Gson; import com.google.gson.JsonSyntaxException; import net.roboconf.core.utils.UriUtils; import net.roboconf.core.utils.Utils; import net.roboconf.target.api.TargetException; /** * @author Vincent Zurczak - Linagora */ public final class DockerUtils { /** * Private empty constructor. */ private DockerUtils() { // nothing } /** * Verifies the Docker client configuration. * @param targetProperties the target properties * @throws TargetException if the configuration is invalid */ public static void verifyDockerClient( Map<String,String> targetProperties ) throws TargetException { String imageId = targetProperties.get( DockerHandler.IMAGE_ID ); String generate = targetProperties.get( DockerHandler.GENERATE_IMAGE ); if( imageId == null && ! Boolean.parseBoolean( generate )) throw new TargetException( "The " + DockerHandler.IMAGE_ID + " parameter was not specified, or enable image generation." ); } /** * Creates a Docker client from target properties. * @param targetProperties a non-null map * @return a Docker client * @throws TargetException if something went wrong */ public static DockerClient createDockerClient( Map<String,String> targetProperties ) throws TargetException { // Validate what needs to be validated. Logger logger = Logger.getLogger( DockerHandler.class.getName()); logger.fine( "Setting the target properties." ); verifyDockerClient( targetProperties ); String edpt = targetProperties.get( DockerHandler.ENDPOINT ); if( Utils.isEmptyOrWhitespaces( edpt )) edpt = "tcp://localhost:4243"; // The configuration is straight-forward. Builder config = DefaultDockerClientConfig.createDefaultConfigBuilder() .withDockerHost( edpt ) .withRegistryUsername( targetProperties.get( DockerHandler.USER )) .withRegistryPassword( targetProperties.get( DockerHandler.PASSWORD )) .withRegistryEmail( targetProperties.get( DockerHandler.EMAIL )) .withApiVersion( targetProperties.get( DockerHandler.VERSION )); // Build the client. DockerClientBuilder clientBuilder = DockerClientBuilder.getInstance( config.build()); return clientBuilder.build(); } /** * Deletes a Docker image if it exists. * @param imageId the image ID (not null) * @param dockerClient a Docker client */ public static void deleteImageIfItExists( String imageId, DockerClient dockerClient ) { if( imageId != null ) { List<Image> images = dockerClient.listImagesCmd().exec(); if( findImageById( imageId, images ) != null ) dockerClient.removeImageCmd( imageId ).withForce( true ).exec(); } } /** * Finds an image by ID or by tag. * @param name an image ID or a tag name (can be null) * @param dockerClient a Docker client (not null) * @return an image, or null if none matched */ public static Image findImageByIdOrByTag( String name, DockerClient dockerClient ) { Image image = null; if( ! Utils.isEmptyOrWhitespaces( name )) { Logger logger = Logger.getLogger( DockerUtils.class.getName()); List<Image> images = dockerClient.listImagesCmd().exec(); if(( image = DockerUtils.findImageById( name, images )) != null ) logger.fine( "Found a Docker image with ID " + name ); else if(( image = DockerUtils.findImageByTag( name, images )) != null ) logger.fine( "Found a Docker image with tag " + name ); } return image; } /** * Finds an image by ID. * @param imageId the image ID (not null) * @param images a non-null list of images * @return an image, or null if none was found */ public static Image findImageById( String imageId, List<Image> images ) { Image result = null; for( Image img : images ) { if( img.getId().equals(imageId)) { result = img; break; } } return result; } /** * Finds an image by tag. * @param imageTag the image tag (not null) * @param images a non-null list of images * @return an image, or null if none was found */ public static Image findImageByTag( String imageTag, List<Image> images ) { Image result = null; for( Image img : images ) { String[] tags = img.getRepoTags(); if( tags == null ) continue; for( String s : tags ) { if( s.contains( imageTag )) { result = img; break; } } } return result; } /** * Finds a container by ID or by name. * @param name the container ID or name (not null) * @param dockerClient a Docker client * @return a container, or null if none was found */ public static Container findContainerByIdOrByName( String name, DockerClient dockerClient ) { Container result = null; List<Container> containers = dockerClient.listContainersCmd().withShowAll( true ).exec(); for( Container container : containers ) { List<String> names = Arrays.asList( container.getNames()); // Docker containers are prefixed with '/'. // At least, those we created, since their parent is the Docker daemon. if( container.getId().equals( name ) || names.contains( "/" + name )) { result = container; break; } } return result; } /** * Gets the state of a container. * @param containerId the container ID * @param dockerClient the Docker client * @return a container state, or null if the container was not found */ public static ContainerState getContainerState( String containerId, DockerClient dockerClient ) { ContainerState result = null; try { InspectContainerResponse resp = dockerClient.inspectContainerCmd( containerId ).exec(); if( resp != null ) result = resp.getState(); } catch( Exception e ) { // nothing } return result; } /** * Finds the options and tries to configure them on the creation command. * @param options the options (key = name, value = option value) * @param cmd a non-null command to create a container * @throws TargetException */ public static void configureOptions( Map<String,String> options, CreateContainerCmd cmd ) throws TargetException { Logger logger = Logger.getLogger( DockerUtils.class.getName()); // Basically, we had two choices: // 1. Map our properties to the Java REST API. // 2. By-pass it and send our custom JSon object. // // The second option is much more complicated. // So, we use Java reflection and some hacks to match Docker properties // with the setter methods available in the API. The API remains in charge // of generating the right JSon objects. Map<String,List<String>> hackedSetterNames = new HashMap<> (); // Remains from Docker-Java 2.x (the mechanism still works) // // List<String> list = new ArrayList<> (); // list.add( "withMemoryLimit" ); // hackedSetterNames.put( "withMemory", list ); // List known types List<Class<?>> types = new ArrayList<> (); types.add( String.class ); types.add( String[].class ); types.add( long.class ); types.add( Long.class ); types.add( int.class ); types.add( Integer.class ); types.add( boolean.class ); types.add( Boolean.class ); types.add( Capability[].class ); // Deal with the options for( Map.Entry<String,String> entry : options.entrySet()) { String optionValue = entry.getValue(); // Now, guess what option to set String methodName = entry.getKey().replace( "-", " " ).trim(); methodName = WordUtils.capitalize( methodName ); methodName = methodName.replace( " ", "" ); methodName = "with" + methodName; Method _m = null; for( Method m : cmd.getClass().getMethods()) { boolean sameMethod = methodName.equalsIgnoreCase( m.getName()); boolean methodWithAlias = hackedSetterNames.containsKey( methodName ) && hackedSetterNames.get( methodName ).contains( m.getName()); if( sameMethod || methodWithAlias ) { // Only one parameter? if( m.getParameterTypes().length != 1 ) { logger.warning( "A method was found for " + entry.getKey() + " but it does not have the right number of parameters." ); continue; } // The right type? if( ! types.contains( m.getParameterTypes()[ 0 ])) { // Since Docker-java 3.x, there are two methods to set cap-add and cap-drop. // One takes an array as parameter, the other takes a list. logger.warning( "A method was found for " + entry.getKey() + " but it does not have the right parameter type. " + "Skipping it. You may want to add a feature request." ); continue; } // That's probably the right one. _m = m; break; } } // Handle errors if( _m == null ) throw new TargetException( "Nothing matched the " + entry.getKey() + " option in the REST API. Please, report it." ); // Try to set the option in the REST client try { Object o = prepareParameter( optionValue, _m.getParameterTypes()[ 0 ]); _m.invoke( cmd, o ); } catch( ReflectiveOperationException | IllegalArgumentException e ) { throw new TargetException( "Option " + entry.getKey() + " could not be set." ); } } } /** * Prepares the parameter to pass it to the REST API. * @param rawValue the raw value, as a string * @param clazz the class associated with the input parameter * @return the object, converted to the right class * @throws TargetException */ public static Object prepareParameter( String rawValue, Class<?> clazz ) throws TargetException { // Simple types Object result; if( clazz == int.class || clazz == Integer.class ) result = Integer.parseInt( rawValue ); else if( clazz == long.class || clazz == Long.class ) result = Long.parseLong( rawValue ); else if( clazz == boolean.class || clazz == Boolean.class ) result = Boolean.parseBoolean( rawValue ); // Arrays of string else if( clazz == String[].class ) { List<String> parts = Utils.splitNicely( rawValue, "," ); result = parts.toArray( new String[ parts.size()]); } // Capabilities else if( clazz == Capability[].class ) { List<Capability> caps = new ArrayList<> (); for( String s : Utils.splitNicely( rawValue, "," )) { try { caps.add( Capability.valueOf( s )); } catch( Exception e ) { throw new TargetException( "Unknown capability: " + s ); } } result = caps.toArray( new Capability[ caps.size()]); } // Default: keep the string else result = rawValue; return result; } /** * Builds the command to pass to a new Docker container. * @param cmd the value of the docker.run.command property * @param messagingConfiguration the messaging configuration (not null) * @param applicationName the application's name * @param scopedInstancePath the scoped instance's path * @return a non-null list of arguments */ public static List<String> buildRunCommand( String cmd, Map<String,String> messagingConfiguration, String applicationName, String scopedInstancePath ) { // We get the custom command line (docker.run.exec property) to run and: // - If nothing/invalid is specified (args == null), we use the standard agent start command // - If an empty command is explicitly specified (args.isEmpty()), there must be a RUN line in the Dockerfile. // - Else we use the provided command line. We may need to inject agent & messaging configuration. List<String> args = parseRunExecLine( cmd ); if( args == null ) { // No docker.run.exec property (or invalid), fall back to the default command line. // Build the command line, passing the agent & messaging configuration. // Command line is: // - Agent's start.sh script // - messaging provider-specific configuration file, // - agent.application-name=<<name of the application>> // - agent.scoped-instance-path=<<path of the scoped instance>> // - agent.messaging-type=<<type of messaging>> // - each of the messaging configuration properties, prefixed by "msg." args = new ArrayList<> (); args.add("/usr/local/roboconf-agent/start.sh"); args.add( "etc/net.roboconf.messaging." + DockerHandler.MARKER_MESSAGING_TYPE + ".cfg"); args.add( "agent.application-name=" + DockerHandler.MARKER_APPLICATION_NAME ); args.add( "agent.scoped-instance-path=" + DockerHandler.MARKER_INSTANCE_PATH ); args.add( "agent.messaging-type=" + DockerHandler.MARKER_MESSAGING_TYPE ); args.add( DockerHandler.MARKER_MESSAGING_CONFIGURATION ); } // Now proceed to argument substitution, using the special markers. for( int i=0; i<args.size(); i++ ) { // The current argument, that may be substituted. String arg = args.get( i ); // The string to substitute to the marker, or null if nothing to substitute. final String s; // The index (in arg) and length of the marker to replace. final int j, l; if( arg.contains( DockerHandler.MARKER_MESSAGING_TYPE )) { j = arg.indexOf(DockerHandler.MARKER_MESSAGING_TYPE); l = DockerHandler.MARKER_MESSAGING_TYPE.length(); s = messagingConfiguration.containsKey( DockerHandler.MESSAGING_TYPE ) ? messagingConfiguration.get( DockerHandler.MESSAGING_TYPE ) : ""; } else if( arg.contains( DockerHandler.MARKER_APPLICATION_NAME )) { j = arg.indexOf(DockerHandler.MARKER_APPLICATION_NAME); l = DockerHandler.MARKER_APPLICATION_NAME.length(); s = applicationName; } else if( arg.contains( DockerHandler.MARKER_INSTANCE_PATH )) { j = arg.indexOf(DockerHandler.MARKER_INSTANCE_PATH); l = DockerHandler.MARKER_INSTANCE_PATH.length(); s = scopedInstancePath; } else { if( arg.equals(DockerHandler.MARKER_MESSAGING_CONFIGURATION )) { // A bit more special: remove the whole argument and appends all // the messaging configuration, prefixed by "msg.". args.remove( i ); for( Map.Entry<String,String> e : messagingConfiguration.entrySet()) { if( DockerHandler.MESSAGING_TYPE.equals( e.getKey())) continue; args.add(i, "msg." + e.getKey() + '=' + e.getValue()); i++; } // We've gone one position to far... i--; } // No in-string substitution. j = -1; l = 0; s = null; } // Proceed to in-string substitution. if( s != null ) { arg = arg.substring(0, j) + s + arg.substring(j + l, arg.length()); args.set(i, arg); } } return args; } /** * Parses the given {@code docker.run.exec} property value. * @param runExecLine the {@code docker.run.exec} property value. * @return the {@code docker.run.exec} command + arguments array. */ public static List<String> parseRunExecLine( String runExecLine ) { List<String> result = null; if( ! Utils.isEmptyOrWhitespaces( runExecLine )) { try { Gson gson = new Gson(); String[] array = gson.fromJson(runExecLine, String[].class); // The returned collection must support the remove operation! // Array.asList() returns an unmodifiable collection. result = new ArrayList<>( Arrays.asList( array )); } catch( JsonSyntaxException e ) { Logger logger = Logger.getLogger( DockerUtils.class.getName()); logger.warning("Cannot parse property " + DockerHandler.RUN_EXEC + ": " + runExecLine); Utils.logException( logger, e ); } } return result; } /** * Handles Boolean values. * <p> * Docker-java 3.x annotates state methods with "@CheckNotNull". * So, we must verify the state attributes are not null (why Boolean instead of boolean?!). * </p> * * @param bool a Boolean value * @return the boolean value, or <code>false</code> if it was null */ public static boolean extractBoolean( Boolean bool ) { return bool != null ? bool.booleanValue() : false; } /** * Downloads a remote file (supports Maven URLs). * <p> * Please, refer to Pax URL's guide for more details about Maven URLs. * https://ops4j1.jira.com/wiki/display/paxurl/Mvn+Protocol * </p> * * @param url an URL * @param targetFile the file where it should be saved * @param mavenResolver the Maven resolver * * @throws IOException * @throws URISyntaxException */ public static void downloadRemotePackage( String url, File targetFile, MavenResolver mavenResolver ) throws IOException, URISyntaxException { if( url.toLowerCase().startsWith( "mvn:" )) { if( mavenResolver == null ) throw new IOException( "Maven URLs are only resolved in Karaf at the moment." ); File sourceFile = mavenResolver.resolve( url ); Utils.copyStream( sourceFile, targetFile ); } else { URL u = UriUtils.urlToUri( url ).toURL(); URLConnection uc = u.openConnection(); InputStream in = null; try { in = new BufferedInputStream( uc.getInputStream()); Utils.copyStream( in, targetFile ); } finally { Utils.closeQuietly( in ); } } } }