diff --git a/doc/changelog.md b/doc/changelog.md index b27f720c6..3c39f9842 100644 --- a/doc/changelog.md +++ b/doc/changelog.md @@ -1,5 +1,8 @@ # ChangeLog +* **0.22-SNAPSHOT** + - Support relative paths when binding volumes in `docker-compose.yml` (#846) + * **0.22.1** (2017-08-28) - Allow Docker compose version "2", too ([#829](https://github.com/fabric8io/docker-maven-plugin/issues/829)) - Allow a registry to be set programmatically ([#853](https://github.com/fabric8io/docker-maven-plugin/issues/853)) diff --git a/src/main/asciidoc/inc/external/_docker_compose.adoc b/src/main/asciidoc/inc/external/_docker_compose.adoc index ba33ae142..714a3d98c 100644 --- a/src/main/asciidoc/inc/external/_docker_compose.adoc +++ b/src/main/asciidoc/inc/external/_docker_compose.adoc @@ -35,7 +35,7 @@ The following options can be provided: | Element | Description | Default | *basedir* -| Basedir where to find the compose file and which is also used as the current directory when examing the compose file +| Basedir where to find the compose file and which is also used as the current directory when examing the compose file. Any relative volume bindings will be resolved relative to this directory. | `${basedir}/src/main/docker` | *composeFile* diff --git a/src/main/asciidoc/inc/start/_volumes.adoc b/src/main/asciidoc/inc/start/_volumes.adoc index 0e443f8db..c4d5df3f2 100644 --- a/src/main/asciidoc/inc/start/_volumes.adoc +++ b/src/main/asciidoc/inc/start/_volumes.adoc @@ -29,7 +29,7 @@ A container can bind (or "mount") volumes from various source when starting up: In this example the container creates a new volume named `/logs` on the container and mounts `/opt/host_export` from the host as `/opt/container_import` on the container. In addition all exported volumes from the container which has been created from the image `jolokia/docker-demo` are mounted directly into the container (with the same directory names under which the exporting container exposes these directories). This image must be also configured for this plugin. Instead of the full image name, an alias name can be used, too. -Please note, that no relative paths are allowed. However, you can use Maven variables in the path specifications. This should even work for boot2docker and docker-machine: +You can use Maven variables in the path specifications. This should even work for boot2docker and docker-machine: .Example with absolute paths [source,xml] @@ -42,4 +42,20 @@ Please note, that no relative paths are allowed. However, you can use Maven vari ---- +You can also use relative paths. Relative paths are interpreted relative to the Maven project base directory. Paths +that begin with `~` are interpreted relative to the JVM's `user.home` directory. + +.Example with relative paths +[source,xml] +---- + + + src/main/webapps/foo:/usr/local/tomcat/webapps/foo + ./target:/data + ~:/home/user + ~/.m2/repository:/home/user/.m2/repository + + +---- + If you wish to mount volumes from an existing container not managed by the plugin, you may do by specifying the container name obtained via `docker ps` in the configuration. diff --git a/src/main/java/io/fabric8/maven/docker/StartMojo.java b/src/main/java/io/fabric8/maven/docker/StartMojo.java index f50a93753..da656dd80 100644 --- a/src/main/java/io/fabric8/maven/docker/StartMojo.java +++ b/src/main/java/io/fabric8/maven/docker/StartMojo.java @@ -235,7 +235,7 @@ private void startImage(final ImageConfiguration image, startingContainers.submit(new Callable() { @Override public StartedContainer call() throws Exception { - final String containerId = runService.createAndStartContainer(image, portMapping, getPomLabel(), projProperties); + final String containerId = runService.createAndStartContainer(image, portMapping, getPomLabel(), projProperties, project.getBasedir()); // Update port-mapping writer portMappingPropertyWriteHelper.add(portMapping, runConfig.getPortPropertyFile()); diff --git a/src/main/java/io/fabric8/maven/docker/config/handler/compose/ComposeUtils.java b/src/main/java/io/fabric8/maven/docker/config/handler/compose/ComposeUtils.java new file mode 100644 index 000000000..54310f3ed --- /dev/null +++ b/src/main/java/io/fabric8/maven/docker/config/handler/compose/ComposeUtils.java @@ -0,0 +1,65 @@ +package io.fabric8.maven.docker.config.handler.compose; + +import io.fabric8.maven.docker.util.DockerPathUtil; +import org.apache.maven.project.MavenProject; + +import java.io.File; +import java.io.IOException; + +/** + * Path-resolution methods + */ +class ComposeUtils { + + /** + * Resolves a docker-compose file against the supplied base directory. The returned {@code File} is guaranteed to + * be {@link File#isAbsolute() absolute}. + *

+ * If {@code composeFile} is {@link File#isAbsolute() absolute}, then it is returned unmodified. Otherwise, the + * {@code composeFile} is returned as an absolute {@code File} using the {@link #resolveAbsolutely(String, + * MavenProject) resolved} {@code baseDir} as its parent. + *

+ * + * @param baseDir the base directory containing the docker-compose file (ignored if {@code composeFile} is absolute) + * @param composeFile the path of the docker-compose file, may be absolute + * @param project the {@code MavenProject} used to resolve the {@code baseDir} + * @return an absolute {@code File} reference to the {@code composeFile} + */ + static File resolveComposeFileAbsolutely(String baseDir, String composeFile, MavenProject project) { + File yamlFile = new File(composeFile); + if (yamlFile.isAbsolute()) { + return yamlFile; + } + + File toCanonicalize = new File(resolveAbsolutely(baseDir, project), composeFile); + + try { + return toCanonicalize.getCanonicalFile(); + } catch (IOException e) { + throw new RuntimeException("Unable to canonicalize the resolved docker-compose file path '" + toCanonicalize + "'"); + } + } + + /** + * Resolves the supplied resource (a path or directory on the filesystem) relative the Maven {@link + * MavenProject#getBasedir() base directory}. The returned {@code File} is guaranteed to be {@link + * File#isAbsolute() absolute}. The returned file is not guaranteed to exist. + *

+ * If {@code pathToResolve} is {@link File#isAbsolute() absolute}, then it is returned unmodified. Otherwise, the + * {@code pathToResolve} is returned as an absolute {@code File} using the {@link MavenProject#getBasedir() Maven + * Project base directory} as its parent. + *

+ * + * @param pathToResolve represents a filesystem resource, which may be an absolute path + * @param project the Maven project used to resolve non-absolute path resources, may be {@code null} if + * {@code pathToResolve} is {@link File#isAbsolute() absolute} + * @return an absolute {@code File} reference to {@code pathToResolve}; not guaranteed to exist + * @throws IllegalArgumentException if {@code pathToResolve} is relative, and {@code project} is {@code null} or + * provides a relative {@link MavenProject#getBasedir() base directory} + */ + static File resolveAbsolutely(String pathToResolve, MavenProject project) { + // avoid an NPE if the Maven project is not needed by DockerPathUtil + return DockerPathUtil.resolveAbsolutely(pathToResolve, + (project == null) ? null : project.getBasedir().getAbsolutePath()); + } +} diff --git a/src/main/java/io/fabric8/maven/docker/config/handler/compose/DockerComposeConfigHandler.java b/src/main/java/io/fabric8/maven/docker/config/handler/compose/DockerComposeConfigHandler.java index 26bccc483..f8db4ed81 100644 --- a/src/main/java/io/fabric8/maven/docker/config/handler/compose/DockerComposeConfigHandler.java +++ b/src/main/java/io/fabric8/maven/docker/config/handler/compose/DockerComposeConfigHandler.java @@ -14,6 +14,9 @@ import org.apache.maven.shared.filtering.MavenReaderFilterRequest; import org.yaml.snakeyaml.Yaml; +import static io.fabric8.maven.docker.config.handler.compose.ComposeUtils.resolveAbsolutely; +import static io.fabric8.maven.docker.config.handler.compose.ComposeUtils.resolveComposeFileAbsolutely; + /** * Docker Compose handler for allowing a docker-compose file to be used @@ -39,7 +42,7 @@ public List resolve(ImageConfiguration unresolvedConfig, Mav List resolved = new ArrayList<>(); DockerComposeConfiguration handlerConfig = new DockerComposeConfiguration(unresolvedConfig.getExternalConfig()); - File composeFile = resolveComposeFile(handlerConfig.getBasedir(), handlerConfig.getComposeFile(), project); + File composeFile = resolveComposeFileAbsolutely(handlerConfig.getBasedir(), handlerConfig.getComposeFile(), project); for (Object composeO : getComposeConfigurations(composeFile, project, session)) { Map compose = (Map) composeO; @@ -49,7 +52,7 @@ public List resolve(ImageConfiguration unresolvedConfig, Mav String serviceName = entry.getKey(); Map serviceDefinition = (Map) entry.getValue(); - DockerComposeServiceWrapper mapper = new DockerComposeServiceWrapper(serviceName, composeFile, serviceDefinition, unresolvedConfig); + DockerComposeServiceWrapper mapper = new DockerComposeServiceWrapper(serviceName, composeFile, serviceDefinition, unresolvedConfig, resolveAbsolutely(handlerConfig.getBasedir(), project)); resolved.add(buildImageConfiguration(mapper, composeFile.getParentFile(), unresolvedConfig, handlerConfig)); } } @@ -211,8 +214,4 @@ private RunImageConfiguration createRunConfiguration(DockerComposeServiceWrapper .build(); } - private File resolveComposeFile(String baseDir, String compose, MavenProject project) { - File yamlFile = new File(compose); - return yamlFile.isAbsolute() ? yamlFile : new File(new File(project.getBasedir(),baseDir),compose); - } } diff --git a/src/main/java/io/fabric8/maven/docker/config/handler/compose/DockerComposeServiceWrapper.java b/src/main/java/io/fabric8/maven/docker/config/handler/compose/DockerComposeServiceWrapper.java index 01a830a2b..ad0745424 100644 --- a/src/main/java/io/fabric8/maven/docker/config/handler/compose/DockerComposeServiceWrapper.java +++ b/src/main/java/io/fabric8/maven/docker/config/handler/compose/DockerComposeServiceWrapper.java @@ -4,6 +4,7 @@ import java.util.*; import io.fabric8.maven.docker.config.*; +import io.fabric8.maven.docker.util.VolumeBindingUtil; class DockerComposeServiceWrapper { @@ -12,13 +13,20 @@ class DockerComposeServiceWrapper { private final String name; private final File composeFile; private final ImageConfiguration enclosingImageConfig; + private final File baseDir; DockerComposeServiceWrapper(String serviceName, File composeFile, Map serviceDefinition, - ImageConfiguration enclosingImageConfig) { + ImageConfiguration enclosingImageConfig, File baseDir) { this.name = serviceName; this.composeFile = composeFile; this.configuration = serviceDefinition; this.enclosingImageConfig = enclosingImageConfig; + + if (!baseDir.isAbsolute()) { + throw new IllegalArgumentException( + "Expected the base directory '" + baseDir + "' to be an absolute path."); + } + this.baseDir = baseDir; } String getAlias() { @@ -249,7 +257,14 @@ RunVolumeConfiguration getVolumeConfig() { builder.from(volumesFrom); added = true; } - return added ? builder.build() : null; + + if (added) { + RunVolumeConfiguration configuration = builder.build(); + VolumeBindingUtil.resolveRelativeVolumeBindings(baseDir, configuration); + return configuration; + } + + return null; } String getDomainname() { @@ -396,4 +411,5 @@ private Map convertToMap(List list) { private void throwIllegalArgumentException(String msg) { throw new IllegalArgumentException(String.format("%s: %s - ", composeFile, name) + msg); } + } diff --git a/src/main/java/io/fabric8/maven/docker/service/RunService.java b/src/main/java/io/fabric8/maven/docker/service/RunService.java index aaa76a0b6..714ea6f69 100644 --- a/src/main/java/io/fabric8/maven/docker/service/RunService.java +++ b/src/main/java/io/fabric8/maven/docker/service/RunService.java @@ -17,6 +17,7 @@ * limitations under the License. */ +import java.io.File; import java.util.*; import java.util.concurrent.Callable; import java.util.concurrent.ExecutionException; @@ -30,6 +31,8 @@ import io.fabric8.maven.docker.wait.WaitUtil; import io.fabric8.maven.docker.wait.WaitTimeoutException; +import static io.fabric8.maven.docker.util.VolumeBindingUtil.resolveRelativeVolumeBindings; + /** * Service class for helping in running containers. @@ -96,11 +99,12 @@ public String execInContainer(String containerId, String command, ImageConfigura public String createAndStartContainer(ImageConfiguration imageConfig, PortMapping portMapping, PomLabel pomLabel, - Properties mavenProps) throws DockerAccessException { + Properties mavenProps, + File baseDir) throws DockerAccessException { RunImageConfiguration runConfig = imageConfig.getRunConfiguration(); String imageName = imageConfig.getName(); String containerName = calculateContainerName(imageConfig.getAlias(), runConfig.getNamingStrategy()); - ContainerCreateConfig config = createContainerConfig(imageName, runConfig, portMapping, pomLabel, mavenProps); + ContainerCreateConfig config = createContainerConfig(imageName, runConfig, portMapping, pomLabel, mavenProps, baseDir); String id = docker.createContainer(config, containerName); startContainer(imageConfig, id, pomLabel); @@ -239,7 +243,7 @@ private List convertToResolvables(List mergeLabels(Map labels, PomLabel run return ret; } - ContainerHostConfig createContainerHostConfig(RunImageConfiguration runConfig, PortMapping mappedPorts) + ContainerHostConfig createContainerHostConfig(RunImageConfiguration runConfig, PortMapping mappedPorts, File baseDir) throws DockerAccessException { RestartPolicy restartPolicy = runConfig.getRestartPolicy(); @@ -309,7 +314,7 @@ ContainerHostConfig createContainerHostConfig(RunImageConfiguration runConfig, P .tmpfs(runConfig.getTmpfs()) .ulimits(runConfig.getUlimits()); - addVolumeConfig(config, runConfig); + addVolumeConfig(config, runConfig, baseDir); addNetworkingConfig(config, runConfig); return config; @@ -326,9 +331,10 @@ private void addNetworkingConfig(ContainerHostConfig config, RunImageConfigurati } } - private void addVolumeConfig(ContainerHostConfig config, RunImageConfiguration runConfig) throws DockerAccessException { + private void addVolumeConfig(ContainerHostConfig config, RunImageConfiguration runConfig, File baseDir) throws DockerAccessException { RunVolumeConfiguration volConfig = runConfig.getVolumeConfiguration(); if (volConfig != null) { + resolveRelativeVolumeBindings(baseDir, volConfig); config.binds(volConfig.getBind()) .volumesFrom(findVolumesFromContainers(volConfig.getFrom())); } diff --git a/src/main/java/io/fabric8/maven/docker/service/WatchService.java b/src/main/java/io/fabric8/maven/docker/service/WatchService.java index c5ee8e4b7..da07c73eb 100644 --- a/src/main/java/io/fabric8/maven/docker/service/WatchService.java +++ b/src/main/java/io/fabric8/maven/docker/service/WatchService.java @@ -236,7 +236,8 @@ public void execute(ImageWatcher watcher) throws Exception { // Start new one watcher.setContainerId(runService.createAndStartContainer(imageConfig, mappedPorts, watcher.getWatchContext().getPomLabel(), - watcher.getWatchContext().getMojoParameters().getProject().getProperties())); + watcher.getWatchContext().getMojoParameters().getProject().getProperties(), + watcher.getWatchContext().getMojoParameters().getProject().getBasedir())); } }; } diff --git a/src/main/java/io/fabric8/maven/docker/util/DockerPathUtil.java b/src/main/java/io/fabric8/maven/docker/util/DockerPathUtil.java new file mode 100644 index 000000000..bfd51e696 --- /dev/null +++ b/src/main/java/io/fabric8/maven/docker/util/DockerPathUtil.java @@ -0,0 +1,57 @@ +package io.fabric8.maven.docker.util; + +import java.io.File; +import java.io.IOException; + +/** + * Docker path resolution and manipulation utility methods. + *

+ * This class provides methods for manipulating paths as they appear in docker-compose or Dockerfiles. This + * class does not provide for generic path manipulation across platforms or file systems. Paths that appear in Docker + * configurations use forward slash as a separator character, so this class makes no provisions for handling Windows + * platform path semantics (e.g. the presence of drive letters or backward slash). + *

+ */ +public class DockerPathUtil { + + /** + * Resolves the supplied resource (a path or directory on the filesystem) relative the supplied {@code + * baseDir}. The returned {@code File} is guaranteed to be {@link File#isAbsolute() absolute}. The returned file + * is not guaranteed to exist. + *

+ * If the supplied {@code pathToResolve} is already {@link File#isAbsolute() absolute}, then it is returned + * unmodified. Otherwise, the {@code pathToResolve} is returned as an absolute {@code File} using the + * supplied {@code baseDir} as its parent. + *

+ * + * @param pathToResolve represents a filesystem resource, which may be an absolute path + * @param baseDir the absolute path used to resolve non-absolute path resources; must be absolute + * @return an absolute {@code File} reference to {@code pathToResolve}; not guaranteed to exist + * @throws IllegalArgumentException if the supplied {@code baseDir} does not represent an absolute path + */ + public static File resolveAbsolutely(String pathToResolve, String baseDir) { + // TODO: handle the case where pathToResolve specifies a non-existent path, for example, a base directory equal to "/" and a relative path of "../../foo". + File fileToResolve = new File(pathToResolve); + + if (fileToResolve.isAbsolute()) { + return fileToResolve; + } + + if (baseDir == null) { + throw new IllegalArgumentException("Cannot resolve relative path '" + pathToResolve + "' with a " + + "null base directory."); + } + + File baseDirAsFile = new File(baseDir); + if (!baseDirAsFile.isAbsolute()) { + throw new IllegalArgumentException("Base directory '" + baseDirAsFile + "' must be absolute"); + } + + final File toCanonicalize = new File(baseDirAsFile, pathToResolve); + try { + return toCanonicalize.getCanonicalFile(); + } catch (IOException e) { + throw new RuntimeException("Unable to canonicalize the file path '" + toCanonicalize + "'"); + } + } +} diff --git a/src/main/java/io/fabric8/maven/docker/util/VolumeBindingUtil.java b/src/main/java/io/fabric8/maven/docker/util/VolumeBindingUtil.java new file mode 100644 index 000000000..05d9dd646 --- /dev/null +++ b/src/main/java/io/fabric8/maven/docker/util/VolumeBindingUtil.java @@ -0,0 +1,345 @@ +package io.fabric8.maven.docker.util; + +import io.fabric8.maven.docker.config.RunVolumeConfiguration; + +import java.io.File; +import java.io.IOException; +import java.util.List; +import java.util.regex.Pattern; + +import static io.fabric8.maven.docker.util.DockerPathUtil.resolveAbsolutely; + +/** + * Utility methods for working with Docker volume bindings. + *

+ * This class provides explicit support for relative binding paths. This means that the plugin configuration or + * docker compose file can specify a relative path when configuring a volume binding. Methods in this class will + * examine volume binding strings in a {@link RunVolumeConfiguration} and resolve any relative paths in the host portion + * of volume bindings. Examples of relative bindings include: + *

+ *
A host path relative to the current working directory
+ *
./relative/path:/absolute/container/path
+ * + *
A host path relative to the current working directory
+ *
relative/path/:/absolute/container/path
+ * + *
A host path relative to the parent of the current working directory
+ *
../relative/path:/absolute/container/path
+ * + *
A host path equal to the current user's home directory
+ *
~:/absolute/container/path
+ * + *
A host path relative to the current user's home directory
+ *
~/relative/path:/absolute/container/path
+ *
+ *

+ *

+ * Understand that the following is not considered a relative binding path, and is instead interpreted as a + * named volume: + *

+ *
{@code rel} is interpreted as a named volume. Use {@code ./rel} or {@code rel/} to have it + * interpreted as a relative path.
+ *
rel:/absolute/container/path
+ *
+ *

+ *

+ * Volume bindings that specify an absolute path for the host portion are preserved and returned unmodified. + *

+ */ +public class VolumeBindingUtil { + + /** + * A dot representing the current working directory + */ + private static final String DOT = "."; + + /** + * A tilde representing the current user's home directory + */ + private static final String TILDE = "~"; + + /** + * The current runtime platform file separator, '/' for *nix, '\' for Windows + */ + private static final String RUNTIME_SEP = System.getProperty("file.separator"); + + /** + * Windows file separator: '\' + */ + private static final String WINDOWS_SEP = "\\"; + + /** + * Unix file separator '/' + */ + private static final String UNIX_SEP = "/"; + + /** + * Matches a windows drive letter followed by a colon and backwards slash. For example, will match: + * 'C:\' or 'x:\'. + */ + private static final Pattern WINDOWS_DRIVE_PATTERN = Pattern.compile("^[A-Za-z]:\\\\.*"); + + /** + * Resolves relative paths in the supplied {@code bindingString}, and returns a binding string that has relative + * paths replaced with absolute paths. If the supplied {@code bindingString} does not contain a relative path, it + * is returned unmodified. + *

Discussion:

+ *

+ * Volumes may be defined inside of {@code service} blocks + * as documented here: + *

+ *
+     * volumes:
+     * # Just specify a path and let the Engine create a volume
+     * - /var/lib/mysql
+     *
+     * # Specify an absolute path mapping
+     * - /opt/data:/var/lib/mysql
+     *
+     * # Path on the host, relative to the Compose file
+     * - ./cache:/tmp/cache
+     *
+     * # User-relative path
+     * - ~/configs:/etc/configs/:ro
+     *
+     * # Named volume
+     * - datavolume:/var/lib/mysql"
+     * 
+ *

+ * This method only operates on volume strings that are relative: beginning with {@code ./}, {@code ../}, or + * {@code ~}. Relative paths beginning with {@code ./} or {@code ../} are absolutized relative to the supplied + * {@code baseDir}, which must be absolute. Paths beginning with {@code ~} are interpreted relative to + * {@code new File(System.getProperty("user.home"))}, and {@code baseDir} is ignored. + *

+ *

+ * Volume strings that do not begin with a {@code ./}, {@code ../}, or {@code ~} are returned as-is. + *

+ *

Examples:

+ *

+ * Given {@code baseDir} equal to "/path/to/basedir" and a {@code bindingString} string equal to + * "./reldir:/some/other/dir", this method returns {@code /path/to/basedir/reldir:/some/other/dir} + *

+ *

+ * Given {@code baseDir} equal to "/path/to/basedir" and a {@code bindingString} string equal to + * "../reldir:/some/other/dir", this method returns {@code /path/to/reldir:/some/other/dir} + *

+ *

+ * Given {@code baseDir} equal to "/path/to/basedir" and a {@code bindingString} string equal to + * "~/reldir:/some/other/dir", this method returns {@code /home/user/reldir:/some/other/dir} + *

+ *

+ * Given {@code baseDir} equal to "/path/to/basedir" and a {@code bindingString} equal to + * "src/test/docker:/some/other/dir", this method returns {@code /path/to/basedir/src/test/docker:/some/other/dir} + *

+ *

+ * Given a {@code bindingString} equal to "foo:/some/other/dir", this method returns {@code foo:/some/other/dir}, + * because {@code foo} is considered to be a named volume, not a relative path. + *

+ * + * @param baseDir the base directory used to resolve relative paths (e.g. beginning with {@code ./}, {@code ../}, + * {@code ~}) present in the {@code bindingString}; must be absolute + * @param bindingString the volume string from the docker-compose file + * @return the volume string, with any relative paths resolved as absolute paths + * @throws IllegalArgumentException if the supplied {@code baseDir} is not absolute + */ + public static String resolveRelativeVolumeBinding(File baseDir, String bindingString) { + + // a 'services:' -> service -> 'volumes:' may be formatted as: + // (https://docs.docker.com/compose/compose-file/compose-file-v2/#volumes-volume_driver) + // + // volumes: + // # Just specify a path and let the Engine create a volume + // - /var/lib/mysql + // + // # Specify an absolute path mapping + // - /opt/data:/var/lib/mysql + // + // # Path on the host, relative to the Compose file + // - ./cache:/tmp/cache + // + // # User-relative path + // - ~/configs:/etc/configs/:ro + // + // # Named volume + // - datavolume:/var/lib/mysql + + String[] pathParts = bindingString.split(":"); + String localPath = pathParts[0]; + + if (isRelativePath(localPath)) { + File resolvedFile; + if (isUserHomeRelativePath(localPath)) { + resolvedFile = resolveAbsolutely(prepareUserHomeRelativePath(localPath), System.getProperty("user.home")); + } else { + if (!baseDir.isAbsolute()) { + throw new IllegalArgumentException("Base directory '" + baseDir + "' must be absolute."); + } + resolvedFile = resolveAbsolutely(localPath, baseDir.getAbsolutePath()); + } + try { + localPath = resolvedFile.getCanonicalFile().getAbsolutePath(); + } catch (IOException e) { + throw new RuntimeException("Unable to canonicalize '" + resolvedFile + "'"); + } + } + + if (pathParts.length > 1) { + pathParts[0] = localPath; + return join(":", pathParts); + } + + return localPath; + } + + /** + * Iterates over each {@link RunVolumeConfiguration#getBind() binding} in the {@code volumeConfiguration}, and + * resolves any relative paths in the binding strings using {@link #resolveRelativeVolumeBinding(File, String)}. + * The {@code volumeConfiguration} is modified in place, with any relative paths replaced with absolute paths. + *

+ * Relative paths are resolved relative to the supplied {@code baseDir}, which must be absolute. + *

+ * + * @param baseDir the base directory used to resolve relative paths (e.g. beginning with {@code ./}, {@code ../}, + * {@code ~}) present in the binding string; must be absolute + * @param volumeConfiguration the volume configuration that may contain volume binding specifications + * @throws IllegalArgumentException if the supplied {@code baseDir} is not absolute + */ + public static void resolveRelativeVolumeBindings(File baseDir, RunVolumeConfiguration volumeConfiguration) { + List bindings = volumeConfiguration.getBind(); + + if (bindings.isEmpty()) { + return; + } + + for (int i = 0; i < bindings.size(); i++) { + bindings.set(i, resolveRelativeVolumeBinding(baseDir, bindings.get(i))); + } + } + + /** + * Determines if the supplied volume binding path contains a relative path. This is subtle, because volume + * bindings may specify a named volume per the discussion below. + *

Discussion:

+ *

+ * Volumes may be defined inside of {@code service} blocks + * as documented here: + *

+ *
+     * volumes:
+     * # Just specify a path and let the Engine create a volume
+     * - /var/lib/mysql
+     *
+     * # Specify an absolute path mapping
+     * - /opt/data:/var/lib/mysql
+     *
+     * # Path on the host, relative to the Compose file
+     * - ./cache:/tmp/cache
+     *
+     * # User-relative path
+     * - ~/configs:/etc/configs/:ro
+     *
+     * # Named volume
+     * - datavolume:/var/lib/mysql"
+     * 
+ *

+ * Volume binding paths that begin with {@code ./}, {@code ../}, or {@code ~} clearly represent a relative path. + * However, binding paths that do not begin with those characters may represent a named volume. For + * example, the binding string {@code rel:/path/to/container/mountpoint} refers to the named volume {@code + * rel}. Because it is desirable to fully support relative paths for volumes provided in a run configuration, this + * method attempts to resolve the ambiguity between a named volume and a relative path. + *

+ *

+ * Therefore, volume binding strings will be considered to contain a relative path when any of the following + * conditions are true: + *

    + *
  • the volume binding path begins with {@code ./}, {@code ../}, or {@code ~}
  • + *
  • the volume binding path contains the character {@code /} and {@code /} is not at index 0 of + * the volume binding path
  • + *
+ *

+ *

+ * If the binding string {@code rel:/path/to/container/mountpoint} is intended to represent {@code rel} as a + * relative path and not as a named volume, then the binding string should be modified to contain + * a forward slash like so: {@code rel/:/path/to/container/mountpoint}. Another option would be to prefix {@code + * rel} with a {@code ./} like so: {@code ./rel:/path/to/container/mountpoint} + *

+ * + * + * @param candidatePath the candidate volume binding path + * @return true if the candidate path is considered to be a relative path + */ + static boolean isRelativePath(String candidatePath) { + + // java.io.File considers Windows paths to be absolute _only_ if they start with a drive letter. That is, + // a Windows path '\foo\bar\baz' is _not_ considered absolute by File#isAbsolute. This block differs from + // java.io.File in that it considers Windows paths to be absolute if they begin with the file separator _or_ a + // drive letter + if (candidatePath.startsWith(UNIX_SEP) || + candidatePath.startsWith(WINDOWS_SEP) || + WINDOWS_DRIVE_PATTERN.matcher(candidatePath).matches()) { + return false; + } + + // './' or '../' + if (candidatePath.startsWith(DOT + RUNTIME_SEP) || candidatePath.startsWith(DOT + DOT + RUNTIME_SEP)) { + return true; + } + + if (candidatePath.contains(UNIX_SEP) || candidatePath.contains(WINDOWS_SEP)) { + return true; + } + + if (isUserHomeRelativePath(candidatePath)) { + return true; + } + + return false; + } + + /** + * Returns true if the supplied path begins with {@code ~}. This means that the path should be resolved relative + * to the user's home directory. + * + * @param candidatePath the candidate path that may represent a path under the user's home directory + * @return true if the path begins with {@code ~} + */ + static boolean isUserHomeRelativePath(String candidatePath) { + return candidatePath.startsWith(TILDE); + } + + private static String prepareUserHomeRelativePath(String userHomePath) { + if (!(isUserHomeRelativePath(userHomePath))) { + return userHomePath; + } + + // Handle ~user and ~/path and ~ + + // '~' + if (userHomePath.equals(TILDE)) { + return ""; + } + + // '~/' + if (userHomePath.startsWith(TILDE + RUNTIME_SEP)) { + return userHomePath.substring(2); + } + + // '~user' is not supported; no logic to support "find the home directory for an arbitrary user". + // e.g. '~user' or '~user/foo' + throw new IllegalArgumentException("'" + userHomePath + "' cannot be relativized, cannot resolve arbitrary" + + " user home paths."); + } + + private static String join(String with, String... components) { + StringBuilder result = new StringBuilder(); + int i = 0; + while (i < components.length) { + result.append(components[i++]); + if (i < components.length) { + result.append(with); + } + } + + return result.toString(); + } +} diff --git a/src/test/java/io/fabric8/maven/docker/config/handler/compose/ComposeUtilsTest.java b/src/test/java/io/fabric8/maven/docker/config/handler/compose/ComposeUtilsTest.java new file mode 100644 index 000000000..0a99b8eb0 --- /dev/null +++ b/src/test/java/io/fabric8/maven/docker/config/handler/compose/ComposeUtilsTest.java @@ -0,0 +1,86 @@ +package io.fabric8.maven.docker.config.handler.compose; + +import mockit.Expectations; +import mockit.Mocked; +import mockit.VerificationsInOrder; +import mockit.integration.junit4.JMockit; +import org.apache.maven.project.MavenProject; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.io.File; + +import static io.fabric8.maven.docker.util.PathTestUtil.DOT; +import static io.fabric8.maven.docker.util.PathTestUtil.SEP; +import static io.fabric8.maven.docker.util.PathTestUtil.createTmpFile; +import static io.fabric8.maven.docker.util.PathTestUtil.join; +import static org.junit.Assert.assertEquals; + +/** + * + */ +@RunWith(JMockit.class) +public class ComposeUtilsTest { + + private final String className = ComposeUtilsTest.class.getSimpleName(); + + private final String ABS_BASEDIR = createTmpFile(className).getAbsolutePath(); + + @Mocked + private MavenProject project; + + @Test + public void resolveComposeFileWithAbsoluteComposeFile() throws Exception { + String absComposeFile = createTmpFile(className).getAbsolutePath() + SEP + "docker-compose.yaml"; + + assertEquals(new File(absComposeFile), + ComposeUtils.resolveComposeFileAbsolutely(null, absComposeFile, null)); + } + + @Test + public void resolveComposeFileWithRelativeComposeFileAndAbsoluteBaseDir() throws Exception { + String relComposeFile = join(SEP, "relative", "path", "to", "docker-compose.yaml"); // relative/path/to/docker-compose.yaml + final String absMavenProjectDir = createTmpFile(className).getAbsolutePath(); + + new Expectations() {{ + project.getBasedir(); + result = new File(absMavenProjectDir); + }}; + + assertEquals(new File(ABS_BASEDIR, relComposeFile), + ComposeUtils.resolveComposeFileAbsolutely(ABS_BASEDIR, relComposeFile, project)); + + new VerificationsInOrder() {{ + project.getBasedir(); + }}; + } + + @Test + public void resolveComposeFileWithRelativeComposeFileAndRelativeBaseDir() throws Exception { + String relComposeFile = join(SEP, "relative", "path", "to", "docker-compose.yaml"); // relative/path/to/docker-compose.yaml + String relBaseDir = "basedir" + SEP; + final String absMavenProjectDir = createTmpFile(className).getAbsolutePath(); + + new Expectations() {{ + project.getBasedir(); + result = new File(absMavenProjectDir); + }}; + + assertEquals(new File(new File(absMavenProjectDir, relBaseDir), relComposeFile), + ComposeUtils.resolveComposeFileAbsolutely(relBaseDir, relComposeFile, project)); + + new VerificationsInOrder() {{ + project.getBasedir(); + }}; + } + + @Test + public void resolveComposesFileWithRelativeComposeFileParentDirectory() throws Exception { + String relComposeFile = join(SEP, DOT + DOT, "relative", "path", "to", "docker-compose.yaml"); // ../relative/path/to/docker-compose.yaml + File tmpDir = createTmpFile(ComposeUtilsTest.class.getName()); + String absBaseDir = tmpDir.getAbsolutePath(); + + assertEquals(new File(tmpDir.getParentFile(), relComposeFile.substring(3)), + ComposeUtils.resolveComposeFileAbsolutely(absBaseDir, relComposeFile, null)); + } +} \ No newline at end of file diff --git a/src/test/java/io/fabric8/maven/docker/config/handler/compose/DockerComposeConfigHandlerTest.java b/src/test/java/io/fabric8/maven/docker/config/handler/compose/DockerComposeConfigHandlerTest.java index f2a0b0e29..7a8dabb81 100644 --- a/src/test/java/io/fabric8/maven/docker/config/handler/compose/DockerComposeConfigHandlerTest.java +++ b/src/test/java/io/fabric8/maven/docker/config/handler/compose/DockerComposeConfigHandlerTest.java @@ -4,14 +4,16 @@ import java.io.FileReader; import java.io.IOException; import java.io.InputStream; +import java.net.URL; import java.util.Arrays; -import java.util.Collections; +import java.util.HashMap; import java.util.List; import java.util.Map; import io.fabric8.maven.docker.config.ImageConfiguration; import io.fabric8.maven.docker.config.RestartPolicy; import io.fabric8.maven.docker.config.RunImageConfiguration; +import io.fabric8.maven.docker.config.RunVolumeConfiguration; import io.fabric8.maven.docker.config.handler.ExternalConfigHandlerException; import mockit.Expectations; import mockit.Injectable; @@ -90,10 +92,21 @@ public void negativeVersionTest() throws IOException, MavenFilteringException { private void setupComposeExpectations(final String file) throws IOException, MavenFilteringException { new Expectations() {{ - File input = getAsFile("/compose/" + file); + final File input = getAsFile("/compose/" + file); unresolved.getExternalConfig(); - result = Collections.singletonMap("composeFile", input.getAbsolutePath()); + result = new HashMap() {{ + put("composeFile", input.getAbsolutePath()); + // provide a base directory that actually exists, so that relative paths referenced by the + // docker-compose.yaml file can be resolved + // (note: this is different than the directory returned by 'input.getParent()') + URL baseResource = this.getClass().getResource("/"); + String baseDir = baseResource.getFile(); + assertNotNull("Classpath resource '/' does not have a File: '" + baseResource, baseDir); + assertTrue("Classpath resource '/' does not resolve to a File: '" + new File(baseDir) + "' does not exist.", new File(baseDir).exists()); + put("basedir", baseDir); + }}; + readerFilter.filter((MavenReaderFilterRequest) any); result = new FileReader(input); }}; @@ -108,7 +121,9 @@ private File getAsFile(String resource) throws IOException { void validateRunConfiguration(RunImageConfiguration runConfig) { - assertEquals(a("/foo", "/tmp:/tmp"), runConfig.getVolumeConfiguration().getBind()); + + validateVolumeConfig(runConfig.getVolumeConfiguration()); + assertEquals(a("CAP"), runConfig.getCapAdd()); assertEquals(a("CAP"), runConfig.getCapDrop()); assertEquals("command.sh", runConfig.getCmd().getShell()); @@ -139,6 +154,63 @@ void validateRunConfiguration(RunImageConfiguration runConfig) { assertEquals(1, policy.getRetry()); } + /** + * Validates the {@link RunVolumeConfiguration} by asserting that: + *
    + *
  • absolute host paths remain absolute
  • + *
  • access controls are preserved
  • + *
  • relative host paths are resolved to absolute paths correctly
  • + *
+ * @param toValidate the {@code RunVolumeConfiguration} being validated + */ + void validateVolumeConfig(RunVolumeConfiguration toValidate) { + final int expectedBindCnt = 4; + final List binds = toValidate.getBind(); + assertEquals("Expected " + expectedBindCnt + " bind statements", expectedBindCnt, binds.size()); + + assertEquals(a("/foo", "/tmp:/tmp:rw", "namedvolume:/volume:ro"), binds.subList(0, expectedBindCnt - 1)); + + // The docker-compose.yml used for testing contains a volume binding string that uses relative paths in the + // host portion. Insure that the relative portion has been resolved properly. + String relativeBindString = binds.get(expectedBindCnt - 1); + assertHostBindingExists(relativeBindString); + } + + /** + * Parses the supplied binding string for the host portion, and insures the host portion actually exists on the + * filesystem. Note this method is designed to accommodate both Windows-style and *nix-style absolute paths. + *

+ * The {@code docker-compose.yml} used for testing contains volume binding strings which are relative. + * When the {@link RunVolumeConfiguration} is built, relative paths in the host portion of the binding string are + * resolved to absolute paths. This method expects a binding string that has already had its relative paths + * resolved to absolute paths. It parses the host portion of the binding string, and asserts that the path + * exists on the system. + *

+ * + * + * @param bindString a volume binding string that contains a host portion that is expected to exist on the local + * system + */ + private void assertHostBindingExists(String bindString) { +// System.err.println(">>>> " + bindString); + + // Extract the host-portion of the volume binding string, accounting for windows platform paths and unix style + // paths. For example: + // C:\Users\foo\Documents\workspaces\docker-maven-plugin\target\test-classes\compose\version:/tmp/version + // and + // /Users/foo/workspaces/docker-maven-plugin/target/test-classes/compose/version:/tmp/version + + File file = null; + if (bindString.indexOf(":") > 1) { + // a unix-style path + file = new File(bindString.substring(0, bindString.indexOf(":"))); + } else { + // a windows-style path with a drive letter + file = new File(bindString.substring(0, bindString.indexOf(":", 2))); + } + assertTrue("The file '" + file + "' parsed from the volume binding string '" + bindString + "' does not exist!", file.exists()); + } + protected void validateEnv(Map env) { assertEquals(2, env.size()); assertEquals("name", env.get("NAME")); @@ -148,4 +220,5 @@ protected void validateEnv(Map env) { protected List a(String ... args) { return Arrays.asList(args); } + } diff --git a/src/test/java/io/fabric8/maven/docker/service/RunServiceTest.java b/src/test/java/io/fabric8/maven/docker/service/RunServiceTest.java index d53c2f4cf..ff3017ed1 100644 --- a/src/test/java/io/fabric8/maven/docker/service/RunServiceTest.java +++ b/src/test/java/io/fabric8/maven/docker/service/RunServiceTest.java @@ -1,7 +1,9 @@ package io.fabric8.maven.docker.service; +import java.io.File; import java.io.IOException; import java.lang.reflect.Field; +import java.net.URL; import java.util.*; import io.fabric8.maven.docker.log.LogOutputSpec; @@ -299,8 +301,8 @@ private void thenStartConfigIsValid() throws IOException { private void whenCreateContainerConfig(String imageName) throws DockerAccessException { PortMapping portMapping = runService.createPortMapping(runConfig, properties); - containerConfig = runService.createContainerConfig(imageName, runConfig, portMapping, null, properties); - startConfig = runService.createContainerHostConfig(runConfig, portMapping); + containerConfig = runService.createContainerConfig(imageName, runConfig, portMapping, null, properties, getBaseDirectory()); + startConfig = runService.createContainerHostConfig(runConfig, portMapping, getBaseDirectory()); } private List bind() { @@ -349,6 +351,10 @@ private String loadFile(String fileName) throws IOException { return IOUtils.toString(getClass().getClassLoader().getResource(fileName)); } + private File getBaseDirectory() { + return new File(getClass().getResource("/").getPath()); + } + private List ports() { return Collections.singletonList("0.0.0.0:11022:22"); } diff --git a/src/test/java/io/fabric8/maven/docker/util/DockerFileUtilTest.java b/src/test/java/io/fabric8/maven/docker/util/DockerFileUtilTest.java index c725eb804..4dec39f7c 100644 --- a/src/test/java/io/fabric8/maven/docker/util/DockerFileUtilTest.java +++ b/src/test/java/io/fabric8/maven/docker/util/DockerFileUtilTest.java @@ -21,9 +21,11 @@ import java.util.Map; import java.util.Properties; +import org.apache.commons.io.FileUtils; import org.codehaus.plexus.util.IOUtil; import org.junit.Test; +import static io.fabric8.maven.docker.util.PathTestUtil.createTmpFile; import static org.junit.Assert.assertEquals; /** @@ -64,10 +66,11 @@ public void interpolate() throws Exception { for (Map.Entry entry : filterMapping.entrySet()) { for (int i = 1; i < 2; i++) { File dockerFile = getDockerfilePath(i, entry.getKey()); - String expected = - IOUtil.toString(new FileReader(dockerFile + ".expected")); - String actual = DockerFileUtil.interpolate(dockerFile, props, entry.getValue()); - assertEquals(expected, actual); + File expectedDockerFile = new File(dockerFile.getParent(), dockerFile.getName() + ".expected"); + File actualDockerFile = createTmpFile(dockerFile.getName()); + FileUtils.write(actualDockerFile, + DockerFileUtil.interpolate(dockerFile, props, entry.getValue()), "UTF-8"); + FileUtils.contentEqualsIgnoreEOL(expectedDockerFile, actualDockerFile, "UTF-8"); } } } @@ -77,4 +80,5 @@ private File getDockerfilePath(int i, String dir) { return new File(classLoader.getResource( String.format("interpolate/%s/Dockerfile_%d", dir, i)).getFile()); } + } diff --git a/src/test/java/io/fabric8/maven/docker/util/DockerPathUtilTest.java b/src/test/java/io/fabric8/maven/docker/util/DockerPathUtilTest.java new file mode 100644 index 000000000..1a9061215 --- /dev/null +++ b/src/test/java/io/fabric8/maven/docker/util/DockerPathUtilTest.java @@ -0,0 +1,121 @@ +package io.fabric8.maven.docker.util; + +import org.junit.Ignore; +import org.junit.Test; + +import java.io.File; +import java.io.IOException; + +import static io.fabric8.maven.docker.util.PathTestUtil.DOT; +import static io.fabric8.maven.docker.util.PathTestUtil.SEP; +import static io.fabric8.maven.docker.util.PathTestUtil.createTmpFile; +import static io.fabric8.maven.docker.util.PathTestUtil.getFirstDirectory; +import static io.fabric8.maven.docker.util.PathTestUtil.join; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +/** + * Path manipulation tests + */ +public class DockerPathUtilTest { + + private final String className = DockerPathUtilTest.class.getSimpleName(); + + /** + * A sample relative path, that does not begin or end with a file.separator character + */ + private final String RELATIVE_PATH = "relative" + SEP + "path"; + + /** + * A sample absolute path, which begins with a file.separator character (or drive letter on the Windows platform) + */ + private final String ABS_BASE_DIR = createTmpFile(className).getAbsolutePath(); + + /** + * A sample relative path (no different than {@link #RELATIVE_PATH}), provided as the member name is + * self-documenting in the test. + */ + private final String REL_BASE_DIR = "base" + SEP + "directory"; + + @Test + public void resolveAbsolutelyWithRelativePath() { + String toResolve = RELATIVE_PATH; // relative/path + String absBaseDir = ABS_BASE_DIR; // /base/directory + + // '/base/directory' and 'relative/path' to '/base/directory/relative/path' + assertEquals(new File(absBaseDir + SEP + toResolve), + DockerPathUtil.resolveAbsolutely(toResolve, absBaseDir)); + } + + @Test + public void resolveAbsolutelyWithRelativePathAndTrailingSlash() { + String toResolve = RELATIVE_PATH + SEP; // relative/path/ + String absBaseDir = ABS_BASE_DIR; // /base/directory + + // '/base/directory' and 'relative/path/' to '/base/directory/relative/path' + assertEquals(new File(absBaseDir + SEP + toResolve), + DockerPathUtil.resolveAbsolutely(toResolve, absBaseDir)); + } + + @Test + public void resolveAbsolutelyWithTrailingSlashWithRelativePath() { + String toResolve = RELATIVE_PATH; // relative/path + String absBaseDir = ABS_BASE_DIR + SEP; // /base/directory/ + + // '/base/directory/' and 'relative/path' to '/base/directory/relative/path' + assertEquals(new File(absBaseDir + toResolve), + DockerPathUtil.resolveAbsolutely(toResolve, absBaseDir)); + } + + @Test(expected = IllegalArgumentException.class) + public void resolveAbsolutelyWithRelativePathAndRelativeBaseDir() throws IllegalArgumentException { + DockerPathUtil.resolveAbsolutely(RELATIVE_PATH, REL_BASE_DIR); + } + + /** + * The supplied base directory is relative, but isn't used because the supplied path is absolute. + */ + @Test + public void resolveAbsolutelyWithAbsolutePathAndRelativeBaseDir() { + String absolutePath = createTmpFile(className).getAbsolutePath(); + assertEquals(new File(absolutePath), DockerPathUtil.resolveAbsolutely(absolutePath, REL_BASE_DIR)); + } + + @Test + public void resolveAbsolutelyWithExtraSlashes() throws Exception { + String toResolve = RELATIVE_PATH + SEP + SEP; // relative/path// + + // '/base/directory' and 'relative/path//' to '/base/directory/relative/path' + assertEquals(new File(ABS_BASE_DIR + SEP + RELATIVE_PATH), + DockerPathUtil.resolveAbsolutely(toResolve, ABS_BASE_DIR)); + } + + @Test + public void resolveAbsolutelyWithRelativeParentPath() throws Exception { + String toResolve = join(SEP, DOT + DOT, RELATIVE_PATH); // ../relative/path + + // '/base/directory' and '../relative/path' to '/base/relative/path' + assertEquals(new File(new File(ABS_BASE_DIR).getParent(), RELATIVE_PATH), + DockerPathUtil.resolveAbsolutely(toResolve, ABS_BASE_DIR)); + } + + @Test + @Ignore("TODO: what does PathUtil do, if anything, when encountering backward slashes?") + public void resolveAbsolutelyWithBackwardSlashes() throws Exception { + String toResolve = RELATIVE_PATH.replace("/", "\\"); + + assertEquals(new File(ABS_BASE_DIR + "/" + RELATIVE_PATH), + DockerPathUtil.resolveAbsolutely(toResolve, ABS_BASE_DIR)); + } + + @Test + @Ignore("TODO: there is no parent to the root directory, so how can '../../relative/path' be resolved?") + public void resolveNonExistentPath() throws Exception { + String toResolve = join(SEP, DOT + DOT, DOT + DOT, "relative", "path"); // ../../relative/path + String rootDir = getFirstDirectory( + createTmpFile(DockerPathUtilTest.class.getName())).getAbsolutePath(); // / + + // '/' and '../../relative/path' to ?? + assertEquals(new File(rootDir, RELATIVE_PATH), DockerPathUtil.resolveAbsolutely(toResolve, rootDir)); + } +} \ No newline at end of file diff --git a/src/test/java/io/fabric8/maven/docker/util/PathTestUtil.java b/src/test/java/io/fabric8/maven/docker/util/PathTestUtil.java new file mode 100644 index 000000000..75c93ef72 --- /dev/null +++ b/src/test/java/io/fabric8/maven/docker/util/PathTestUtil.java @@ -0,0 +1,184 @@ +package io.fabric8.maven.docker.util; + +import java.io.File; +import java.io.IOException; + +import static org.junit.Assert.assertTrue; + +/** + * Utility methods and constants for path-related tests + */ +public class PathTestUtil { + + /** + * A dot representing the current working directory + */ + public static final String DOT = "."; + + /** + * A tilde representing the current user's home directory + */ + public static final String TILDE = "~"; + + /** + * The current runtime platform file separator + */ + public static final String SEP = System.getProperty("file.separator"); + + /** + * Joins the supplied strings. + * + * @param joinWith the string used to join the strings + * @param objects the strings to be joined + * @return the joined strings + */ + public static String join(String joinWith, String... objects) { + return join(joinWith, false, false, objects); + } + + /** + * Joins the supplied strings, optionally prefixing and postfixing the returned string. + * + * @param joinWith the string used to join the strings + * @param prefix prefix the returned string with {@code joinWith} + * @param postfix postfix the returned string with {@code joinWith} + * @param objects the strings to be joined + * @return the joined strings + */ + public static String join(String joinWith, boolean prefix, boolean postfix, String... objects) { + StringBuilder sb = null; + if (prefix) { + sb = new StringBuilder(joinWith); + } else { + sb = new StringBuilder(); + } + + for (int i = 0; i < objects.length; ) { + sb.append(objects[i]); + if (i++ < objects.length) { + sb.append(joinWith); + } + } + + if (postfix) { + sb.append(joinWith); + } + + return sb.toString(); + } + + /** + * Strips "." off of the {@code path}, if present. + * + * @param path the path which may begin with a "." + * @return the path stripped of a "." + */ + public static String stripLeadingPeriod(String path) { + if (path.startsWith(DOT)) { + return path.substring(1); + } + + return path; + } + + /** + * Strips "~" off of the {@code path}, if present. + * + * @param path the path which may begin with a "~" + * @return the path stripped of a "~" + */ + public static String stripLeadingTilde(String path) { + if (path.startsWith(TILDE)) { + return path.substring(1); + } + + return path; + } + + /** + * Creates a unique file under {@code java.io.tmpdir} and returns the {@link File#getCanonicalFile() canonical} + * {@code File}. The file is deleted on exit. This methodology + *
    + *
  1. guarantees a unique file name,
  2. + *
  3. doesn't clutter the filesystem with test-related directories or files,
  4. + *
  5. returns an absolute path (important for relative volume binding strings), + *
  6. and returns a canonical file name.
  7. + *
+ * + * @param nameHint a string used to help create the temporary file name, may be {@code null} + * @return the temporary file + */ + public static File createTmpFile(String nameHint) { + return createTmpFile(nameHint, TMP_FILE_PRESERVE_MODE.DELETE_ON_EXIT); + } + + /** + * Creates a unique file under {@code java.io.tmpdir} and returns the {@link File#getCanonicalFile() canonical} + * {@code File}. The optional {@code preserveMode} parameter dictates who is responsible for deleting the created + * file, and when. This methodology + *
    + *
  1. guarantees a unique file name,
  2. + *
  3. doesn't clutter the filesystem with test-related directories or files,
  4. + *
  5. returns an absolute path (important for relative volume binding strings), + *
  6. and returns a canonical file name.
  7. + *
+ * + * @param nameHint a string used to help create the temporary file name, may be {@code null} + * @param preserveMode mechanism for handling the clean up of files created by this method, may be {@code null} + * which is equivalent to {@link TMP_FILE_PRESERVE_MODE#DELETE_ON_EXIT} + * @return the absolute temporary file, which may not exist depending on the {@code preserveMode} + */ + public static File createTmpFile(String nameHint, TMP_FILE_PRESERVE_MODE preserveMode) { + try { + File tmpFile = File.createTempFile(nameHint, ".tmp"); + assertTrue("The created temporary file " + tmpFile + " is not absolute!", tmpFile.isAbsolute()); + if (preserveMode != null) { + switch (preserveMode) { + case DELETE_IMMEDIATELY: + assertTrue("Unable to delete temporary file " + tmpFile, tmpFile.delete()); + break; + case DELETE_ON_EXIT: + tmpFile.deleteOnExit(); + break; + // PRESERVE is a no-op + } + } else { + // default when preserveMode is null + tmpFile.deleteOnExit(); + } + return tmpFile.getCanonicalFile(); + } catch (IOException e) { + throw new RuntimeException("Unable to create or canonicalize temporary directory"); + } + } + + public static File getFirstDirectory(File file) { + File result = file; + while (result.getParentFile() != null) { + result = result.getParentFile(); + } + + return result; + } + + /** + * Modes for handling the removal of created temporary files + */ + public enum TMP_FILE_PRESERVE_MODE { + + /** + * Deletes the created file immediately + */ + DELETE_IMMEDIATELY, + + /** + * Asks the JVM to delete the file on exit + */ + DELETE_ON_EXIT, + + /** + * Preserve the file, do not delete it. The caller is responsible for clean up. + */ + PRESERVE + }; +} diff --git a/src/test/java/io/fabric8/maven/docker/util/VolumeBindingUtilTest.java b/src/test/java/io/fabric8/maven/docker/util/VolumeBindingUtilTest.java new file mode 100644 index 000000000..e19299a35 --- /dev/null +++ b/src/test/java/io/fabric8/maven/docker/util/VolumeBindingUtilTest.java @@ -0,0 +1,305 @@ +package io.fabric8.maven.docker.util; + +import io.fabric8.maven.docker.config.RunVolumeConfiguration; +import org.junit.Ignore; +import org.junit.Test; + +import java.io.File; + +import static io.fabric8.maven.docker.util.PathTestUtil.DOT; +import static io.fabric8.maven.docker.util.PathTestUtil.TILDE; +import static io.fabric8.maven.docker.util.PathTestUtil.TMP_FILE_PRESERVE_MODE.DELETE_IMMEDIATELY; +import static io.fabric8.maven.docker.util.PathTestUtil.createTmpFile; +import static io.fabric8.maven.docker.util.PathTestUtil.join; +import static io.fabric8.maven.docker.util.PathTestUtil.stripLeadingPeriod; +import static io.fabric8.maven.docker.util.VolumeBindingUtil.isRelativePath; +import static io.fabric8.maven.docker.util.VolumeBindingUtil.isUserHomeRelativePath; +import static io.fabric8.maven.docker.util.VolumeBindingUtil.resolveRelativeVolumeBinding; +import static io.fabric8.maven.docker.util.VolumeBindingUtil.resolveRelativeVolumeBindings; +import static java.lang.String.format; +import static java.util.Collections.singletonList; +import static junit.framework.TestCase.assertTrue; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; + +/** + * + */ +public class VolumeBindingUtilTest { + + private static final String CLASS_NAME = VolumeBindingUtilTest.class.getSimpleName(); + + private static final String SEP = System.getProperty("file.separator"); + + /** + * An absolute file path that represents a base directory. It is important for the JVM to create the the file so + * that the absolute path representation of the test platform is used. + */ + private static final File ABS_BASEDIR = createTmpFile(CLASS_NAME, DELETE_IMMEDIATELY); + + /** + * Host portion of a volume binding string representing a directory relative to the current working directory. + */ + private static final String RELATIVE_PATH = DOT + SEP + "rel"; // ./rel + + /** + * Host portion of a volume binding string representing a directory relative to the current user's home directory. + */ + private static final String USER_PATH = TILDE + SEP + "relUser"; // ~/relUser + + /** + * Host portion of a volume binding string representing the current user's home directory. + */ + private static final String USER_HOME = TILDE + "user"; // ~user + + /** + * Container portion of a volume binding string; the location in the container where the host portion is mounted. + */ + private static final String CONTAINER_PATH = "/path/to/container/dir"; + + /** + * Format of a volume binding string that does not have any access controls. Format is: host binding string + * portion, container binding string portion + */ + private final String BIND_STRING_FMT = "%s:%s"; + + /** + * Format of a volume binding string that contains access controls. Format is: host binding string portion, + * container binding string portion, access control portion. + */ + private final String BIND_STRING_WITH_ACCESS_FMT = "%s:%s:%s"; + + /** + * Access control portion of a volume binding string. + */ + private final String RO_ACCESS = "ro"; + + /** + * Insures the supplied base directory is absolute. + */ + @Test(expected = IllegalArgumentException.class) + public void relativeBaseDir() { + resolveRelativeVolumeBinding(new File("relative/"), + format(BIND_STRING_FMT, RELATIVE_PATH, CONTAINER_PATH)); + } + + /** + * Insures that a host volume binding string that contains a path relative to the current working directory is + * resolved to the supplied base directory. + */ + @Test + public void testResolveRelativeVolumePath() { + String volumeString = format(BIND_STRING_FMT, RELATIVE_PATH, CONTAINER_PATH); + + // './rel:/path/to/container/dir' to '/absolute/basedir/rel:/path/to/container/dir' + String relativizedVolumeString = resolveRelativeVolumeBinding(ABS_BASEDIR, volumeString); + + String expectedBindingString = format(BIND_STRING_FMT, + new File(ABS_BASEDIR, stripLeadingPeriod(RELATIVE_PATH)), CONTAINER_PATH); + assertEquals(expectedBindingString, relativizedVolumeString); + } + + /** + * Insures that a host volume binding string that contains a path relative to the current working directory and + * specifies access controls resolves to the supplied base directory and that the access controls are + * preserved through the operation. + */ + @Test + public void testResolveRelativeVolumePathWithAccessSpecifications() { + String volumeString = format(BIND_STRING_WITH_ACCESS_FMT, RELATIVE_PATH, CONTAINER_PATH, RO_ACCESS); + + // './rel:/path/to/container/dir:ro' to '/absolute/basedir/rel:/path/to/container/dir:ro' + String relativizedVolumeString = resolveRelativeVolumeBinding(ABS_BASEDIR, volumeString); + + String expectedBindingString = format(BIND_STRING_WITH_ACCESS_FMT, + new File(ABS_BASEDIR, stripLeadingPeriod(RELATIVE_PATH)), CONTAINER_PATH, RO_ACCESS); + assertEquals(expectedBindingString, relativizedVolumeString); + } + + /** + * Insures that a host volume binding string that contains a path relative to the user's home directory resolves to + * the user's home directory and not the supplied base directory. + */ + @Test + public void testResolveUserVolumePath() { + String volumeString = format(BIND_STRING_FMT, USER_PATH, CONTAINER_PATH); + + // '~/rel:/path/to/container/dir' to '/user/home/rel:/path/to/container/dir' + String relativizedVolumeString = resolveRelativeVolumeBinding(new File("ignored"), volumeString); + + String expectedBindingString = format(BIND_STRING_FMT, + new File(System.getProperty("user.home"), PathTestUtil.stripLeadingTilde(USER_PATH)), CONTAINER_PATH); + assertEquals(expectedBindingString, relativizedVolumeString); + } + + /** + * Resolving arbitrary user home paths, e.g. represented as {@code ~user}, is not supported. + */ + @Test(expected = IllegalArgumentException.class) + public void testResolveUserHomeVolumePath() { + String volumeString = format(BIND_STRING_FMT, USER_HOME, CONTAINER_PATH); + + // '~user:/path/to/container/dir' to '/home/user:/path/to/container/dir' + String relativizedVolumeString = resolveRelativeVolumeBinding(new File("ignored"), volumeString); + } + + /** + * Insures that volume binding strings referencing a named volume are preserved untouched. + */ + @Test + public void testResolveNamedVolume() throws Exception { + String volumeName = "volname"; + String volumeString = format(BIND_STRING_FMT, volumeName, CONTAINER_PATH); + + // volumeString should be untouched + assertEquals(volumeString, resolveRelativeVolumeBinding(ABS_BASEDIR, volumeString)); + } + + /** + * Insures that volume binding strings that contain an absolute path for the host portion are preserved untouched. + */ + @Test + public void testResolveAbsolutePathMapping() { + String absolutePath = + createTmpFile(VolumeBindingUtilTest.class.getSimpleName(), DELETE_IMMEDIATELY).getAbsolutePath(); + String volumeString = format(BIND_STRING_FMT, absolutePath, CONTAINER_PATH); + + // volumeString should be untouched + assertEquals(volumeString, resolveRelativeVolumeBinding(ABS_BASEDIR, volumeString)); + } + + /** + * Insures that volume binding strings with an absolute host portion are returned unchanged (no resolution necessary + * because the the path is absolute) + */ + @Test + public void testResolveSinglePath() { + String absolutePath = + createTmpFile(VolumeBindingUtilTest.class.getSimpleName(), DELETE_IMMEDIATELY).getAbsolutePath(); + + // volumeString should be untouched + assertEquals(absolutePath, resolveRelativeVolumeBinding(ABS_BASEDIR, absolutePath)); + } + + /** + * Insures that relative paths in the host portion of a volume binding string are properly resolved against a base + * directory when present in a {@link RunVolumeConfiguration}. + */ + @Test + public void testResolveVolumeBindingsWithRunVolumeConfiguration() { + RunVolumeConfiguration.Builder builder = new RunVolumeConfiguration.Builder(); + builder.bind(singletonList(format(BIND_STRING_FMT, RELATIVE_PATH, CONTAINER_PATH))); + RunVolumeConfiguration volumeConfiguration = builder.build(); + + + // './rel:/path/to/container/dir' to '/absolute/basedir/rel:/path/to/container/dir' + resolveRelativeVolumeBindings(ABS_BASEDIR, volumeConfiguration); + + String expectedBindingString = format(BIND_STRING_FMT, + join("", ABS_BASEDIR.getAbsolutePath(), + stripLeadingPeriod(RELATIVE_PATH)), CONTAINER_PATH); + assertEquals(expectedBindingString, volumeConfiguration.getBind().get(0)); + } + + /** + * Insures that a relative path referencing the parent directory are properly resolved against a base directory. + */ + @Test + public void testResolveParentRelativeVolumePath() { + String relativePath = DOT + RELATIVE_PATH; // '../rel' + String volumeString = format(BIND_STRING_FMT, relativePath, CONTAINER_PATH); + + // '../rel:/path/to/container/dir to '/absolute/rel:/path/to/container/dir' + String relativizedVolumeString = resolveRelativeVolumeBinding(ABS_BASEDIR, volumeString); + + String expectedBindingString = format(BIND_STRING_FMT, + new File(ABS_BASEDIR.getParent(), stripLeadingPeriod(RELATIVE_PATH)), CONTAINER_PATH); + assertEquals(expectedBindingString, relativizedVolumeString); + } + + /** + * Insures that a relative path referencing the parent directory are properly resolved against a base directory. + */ + @Test + @Ignore("TODO: fix this test, and DockerPathUtil as well") + public void testResolveParentRelativeVolumePathWithNoParent() { + String relativePath = join(SEP, DOT + DOT, DOT + DOT, "rel"); // '../../rel' + String volumeString = format(BIND_STRING_FMT, relativePath, CONTAINER_PATH); + File baseDir = PathTestUtil.getFirstDirectory(ABS_BASEDIR); + + // '../../rel:/path/to/container/dir to '/absolute/rel:/path/to/container/dir' + String relativizedVolumeString = resolveRelativeVolumeBinding(baseDir, volumeString); + + String expectedBindingString = format(BIND_STRING_FMT, + new File(baseDir.getParent(), stripLeadingPeriod(RELATIVE_PATH)), CONTAINER_PATH); + assertEquals(expectedBindingString, relativizedVolumeString); + } + + /** + * The volume binding string: {@code rel:/path/to/container/mountpoint} is not resolved, because {@code rel} is + * considered a named volume. + */ + @Test + public void testResolveRelativeVolumePathWithoutCurrentDirectory() throws Exception { + String relativePath = "rel"; + String volumeString = format(BIND_STRING_FMT, relativePath, CONTAINER_PATH); + + // 'rel:/path/to/container/dir' to 'rel:/path/to/container/dir' + String relativizedVolumeString = resolveRelativeVolumeBinding(ABS_BASEDIR, volumeString); + + String expectedBindingString = format(BIND_STRING_FMT, relativePath, CONTAINER_PATH); + assertEquals(expectedBindingString, relativizedVolumeString); + } + + /** + * The volume binding string: {@code src/test/docker:/path/to/container/mountpoint} is resolved, because {@code src/ + * test/docker} is considered a relative path. + */ + @Test + public void testResolveRelativeVolumePathContainingSlashes() throws Exception { + String relativePath = "src" + SEP + "test" + SEP + "docker"; + String volumeString = format(BIND_STRING_FMT, relativePath, CONTAINER_PATH); + + // 'src/test/docker:/path/to/container/dir' to '/absolute/basedir/src/test/docker:/path/to/container/dir' + String relativizedVolumeString = resolveRelativeVolumeBinding(ABS_BASEDIR, volumeString); + + String expectedBindingString = format(BIND_STRING_FMT, + new File(ABS_BASEDIR, relativePath), CONTAINER_PATH); + assertEquals(expectedBindingString, relativizedVolumeString); + } + + @Test + public void testIsRelativePath() throws Exception { + assertTrue(isRelativePath("rel" + SEP)); // rel/ + assertTrue(isRelativePath(join(SEP, "src", "test", "docker"))); // src/test/docker + assertTrue(isRelativePath(join(SEP, DOT, "rel"))); // ./rel + assertTrue(isRelativePath(join(SEP, TILDE, "rel"))); // ~/rel + assertTrue(isRelativePath(join(SEP, DOT + DOT, "rel"))); // ../rel + assertFalse(isRelativePath("rel")); // 'rel' is a named volume in this case + assertFalse(isRelativePath( + createTmpFile(VolumeBindingUtilTest.class.getSimpleName(), DELETE_IMMEDIATELY) + .getAbsolutePath())); // is absolute + } + + @Test + public void testIsUserRelativeHomeDir() throws Exception { + assertFalse(isUserHomeRelativePath(join(TILDE, "foo", "bar"))); // foo~bar + assertFalse(isUserHomeRelativePath("foo" + TILDE)); // foo~ + assertFalse(isUserHomeRelativePath("foo")); // foo + assertTrue(isUserHomeRelativePath(TILDE + "user")); // ~user + assertTrue(isUserHomeRelativePath(join(SEP, TILDE, "dir"))); // ~/dir + assertTrue(isUserHomeRelativePath(join(SEP, TILDE + "user", "dir"))); // ~user/dir + } + + /** + * Test windows paths even if the test JVM runtime is on *nix, specifically the consideration of an 'absolute' + * path by {@link VolumeBindingUtil#isRelativePath(String)}. + */ + @Test + public void testIsRelativePathForWindows() { + assertFalse(isRelativePath("C:\\foo")); // C:\foo + assertFalse(isRelativePath("x:\\bar")); // x:\bar + assertFalse(isRelativePath("C:\\")); // C:\ + assertFalse(isRelativePath("\\")); // \ + } +} \ No newline at end of file diff --git a/src/test/resources/compose/docker-compose.yml b/src/test/resources/compose/docker-compose.yml index 9f2fff7a1..89ebb9fef 100644 --- a/src/test/resources/compose/docker-compose.yml +++ b/src/test/resources/compose/docker-compose.yml @@ -59,7 +59,13 @@ services: user: tomcat volumes: - /foo - - /tmp:/tmp + # mount /tmp with rw access control + - /tmp:/tmp:rw + # A named volume with access control + - namedvolume:/volume:ro + # ${project.build.directory}/test-classes/compose/version/ should actually exist, because + # src/test/resources/compose is recursively copied to target/test-classes/compose by the Maven lifecycle + - compose/version/:/tmp/version volumes_from: - from working_dir: foo \ No newline at end of file