├── .gitignore ├── LICENSE ├── README.md ├── build.sbt ├── examples ├── basic-native-packager │ ├── build.sbt │ ├── docker │ │ └── docker-compose.yml │ ├── project │ │ ├── build.properties │ │ └── plugins.sbt │ └── src │ │ └── main │ │ └── scala │ │ └── BasicApp.scala ├── basic-variable-substitution │ ├── build.sbt │ ├── docker │ │ └── docker-compose.yml │ ├── project │ │ ├── build.properties │ │ └── plugins.sbt │ └── src │ │ └── main │ │ └── scala │ │ └── BasicApp.scala ├── basic-with-tests-cucumber │ ├── build.sbt │ ├── docker │ │ └── docker-compose.yml │ ├── project │ │ ├── build.properties │ │ └── plugins.sbt │ └── src │ │ ├── main │ │ └── scala │ │ │ └── example │ │ │ ├── Calculator.scala │ │ │ ├── CalculatorClient.scala │ │ │ ├── CalculatorServer.scala │ │ │ └── ClientApp.scala │ │ └── test │ │ ├── resources │ │ ├── calculator-service.feature │ │ └── calculator.feature │ │ └── scala │ │ ├── CalculatorSteps.scala │ │ ├── CucumberTest.scala │ │ └── ServiceSteps.scala ├── basic-with-tests-integration │ ├── build.sbt │ ├── docker │ │ └── docker-compose.yml │ ├── project │ │ ├── build.properties │ │ └── plugins.sbt │ └── src │ │ ├── it │ │ └── scala │ │ │ └── BasicAppSpec.scala │ │ └── main │ │ └── scala │ │ └── BasicApp.scala ├── basic-with-tests-specs2 │ ├── build.sbt │ ├── docker │ │ └── docker-compose.yml │ ├── project │ │ ├── build.properties │ │ └── plugins.sbt │ └── src │ │ ├── main │ │ └── scala │ │ │ └── BasicApp.scala │ │ └── test │ │ └── scala │ │ └── BasicAppSpec.scala ├── basic-with-tests │ ├── build.sbt │ ├── docker │ │ └── docker-compose.yml │ ├── project │ │ ├── build.properties │ │ └── plugins.sbt │ └── src │ │ ├── main │ │ └── scala │ │ │ └── BasicApp.scala │ │ └── test │ │ └── scala │ │ └── BasicAppSpec.scala ├── multi-project │ ├── build.sbt │ ├── docker │ │ └── docker-compose.yml │ ├── project │ │ ├── build.properties │ │ └── plugins.sbt │ ├── sample1 │ │ ├── docker │ │ │ └── docker-compose.yml │ │ └── src │ │ │ └── main │ │ │ └── scala │ │ │ └── BasicApp.scala │ └── sample2 │ │ ├── docker │ │ └── docker-compose.yml │ │ └── src │ │ └── main │ │ └── scala │ │ └── BasicApp.scala └── no-build │ ├── build.sbt │ ├── docker │ └── docker-compose.yml │ └── project │ ├── build.properties │ └── plugins.sbt ├── project ├── build.properties └── plugins.sbt ├── screenshots └── dockerComposeUp.png ├── src ├── main │ └── scala │ │ └── com │ │ └── tapad │ │ └── docker │ │ ├── ComposeCustomTagHelpers.scala │ │ ├── ComposeFile.scala │ │ ├── ComposeInstancePersistence.scala │ │ ├── ComposeTestRunner.scala │ │ ├── DockerCommands.scala │ │ ├── DockerComposeKeys.scala │ │ ├── DockerComposePlugin.scala │ │ ├── DockerComposeSettings.scala │ │ ├── ExecuteInput.scala │ │ ├── OutputTable.scala │ │ ├── OutputTableRow.scala │ │ ├── PrintFormatting.scala │ │ ├── SettingsHelper.scala │ │ └── Version.scala └── test │ ├── resources │ ├── compose_1.6_format.yml │ ├── custom_network.yml │ ├── custom_network_external.yml │ ├── custom_network_multiple.yml │ ├── data │ │ └── data.csv │ ├── debug_port.yml │ ├── debug_port_alternate_environment_format.yml │ ├── debug_port_not_exposed.yml │ ├── debug_port_single.yml │ ├── docker_inspect.json │ ├── docker_inspect2.0.json │ ├── docker_port.txt │ ├── env_file.yml │ ├── env_file_invalid.yml │ ├── localbuild_tag.yml │ ├── multi_service_no_tags.yml │ ├── no_custom_tags.yml │ ├── no_exposed_ports.yml │ ├── port_expansion.yml │ ├── port_expansion_invalid.yml │ ├── skippull_tag.yml │ ├── sort.yml │ ├── test.env │ ├── test2.env │ ├── unsupported_field_build.yml │ ├── unsupported_field_container_name.yml │ ├── unsupported_field_extends.yml │ ├── variable_substitution.yml │ ├── variable_substitution_default_value.yml │ ├── version_number.yml │ ├── volumes.yml │ └── volumes_access_level.yml │ └── scala │ ├── ComposeFileProcessingSpec.scala │ ├── ComposeInstancesSpec.scala │ ├── ImageBuildingSpec.scala │ ├── ImagePullingSpec.scala │ ├── InstancePersistenceSpec.scala │ ├── InstanceRestartingSpec.scala │ ├── InstanceStoppingSpec.scala │ ├── MockHelpers.scala │ ├── PluginGeneralSpec.scala │ ├── PrintFormattingSpec.scala │ ├── TagProcessingSpec.scala │ └── VersionSpec.scala └── version.sbt /.gitignore: -------------------------------------------------------------------------------- 1 | *~ 2 | .DS_Store 3 | .idea 4 | target/* 5 | project/target/* 6 | project/project/* 7 | project/license.sbt 8 | project/credentials.sbt 9 | examples/*/project/project 10 | examples/*/target/* 11 | examples/*/*/target/ -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016 Tapad, Inc. 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 10 | * Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | 14 | * Neither the name of sbt-docker-compose nor the names of its 15 | contributors may be used to endorse or promote products derived from 16 | this software without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 19 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 20 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 22 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 23 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 24 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 25 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 26 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 27 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | sbt-docker-compose 2 | ================== 3 | 4 | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/com.tapad/sbt-docker-compose/badge.svg)](https://maven-badges.herokuapp.com/maven-central/com.tapad/sbt-docker-compose) 5 | 6 | About 7 | ----- 8 | sbt-docker-compose is an sbt plugin that integrates the functionality of [Docker Compose](https://docs.docker.com/compose/) 9 | directly into the sbt build environment. This allows you to make code changes and with one sbt command start up a local 10 | running instance of your latest changes connected to all of its dependencies for live testing and debugging. This plugin 11 | is designed to be extended to allow for instances to be launched in non-local environments such as AWS and Mesos. 12 | 13 | ![Alt text](/screenshots/dockerComposeUp.png?raw=true "dockerComposeUp output") 14 | 15 | Prerequisites 16 | ------------- 17 | You must have [Docker](https://docs.docker.com/engine/installation/) and 18 | [Docker-Compose](https://docs.docker.com/compose/install/) installed. 19 | 20 | Steps to Enable and Configure sbt-docker-compose 21 | ------------------------------------------------ 22 | 23 | 1) Add the sbt-docker-compose plugin to your projects plugins.sbt file: 24 | ``` 25 | addSbtPlugin("com.tapad" % "sbt-docker-compose" % "1.0.35") 26 | ``` 27 | sbt-docker-compose is an auto-plugin which requires that sbt version 0.13.5+ or sbt version 1.0.0+ be used. 28 | 29 | 2) Enable the plugin on the sbt projects you would like to test: 30 | ``` 31 | enablePlugins(DockerComposePlugin) 32 | ``` 33 | 34 | 3) Configure your sbt project(s) to build Docker images by setting the 'dockerImageCreationTask': 35 | 36 | - The [sbt-docker](https://github.com/marcuslonnberg/sbt-docker) plugin can be used by setting: 37 | ``` 38 | dockerImageCreationTask := docker.value 39 | ``` 40 | 41 | - The [sbt-native-packager](https://github.com/sbt/sbt-native-packager) plugin can be used by setting: 42 | ``` 43 | dockerImageCreationTask := (publishLocal in Docker).value 44 | ``` 45 | See the [basic-native-packager](examples/basic-native-packager) example for more details. 46 | 47 | 4) Define a [docker-compose.yml](https://docs.docker.com/compose/compose-file/) file which describes your component, 48 | its dependent images and the links between them. This path to this file can be explicitly defined or by default the 49 | plugin will attempt to locate it in one of three places with the precedence order: 50 | 51 | - The 'resources' directory of the project as defined by the sbt 'resourceDirectory' setting. 52 | 53 | - A 'docker' directory off of the root of the project. 54 | 55 | - In the root of the project directory. 56 | 57 | 5) Optional: There are a number of optional Keys that can be set as well if you want to override the default settings: 58 | ``` 59 | composeContainerPauseBeforeTestSeconds := //Delay between containers start and test execution, seconds. Default is 0 seconds - no delay 60 | composeFile := // Specify the full path to the Compose File to use to create your test instance. It defaults to docker-compose.yml in your resources folder. 61 | composeServiceName := // Specify the name of the service in the Docker Compose file being tested. This setting prevents the service image from being pull down from the Docker Registry. It defaults to the sbt Project name. 62 | composeServiceVersionTask := // The version to tag locally built images with in the docker-compose file. This defaults to the 'version' SettingKey. 63 | composeNoBuild := // True if a Docker Compose file is to be started without building any images and only using ones that already exist in the Docker Registry. This defaults to False. 64 | composeRemoveContainersOnShutdown := // True if a Docker Compose should remove containers when shutting down the compose instance. This defaults to True. 65 | composeRemoveNetworkOnShutdown := // True if a Docker Compose should remove the network it created when shutting down the compose instance. This defaults to True. 66 | composeContainerStartTimeoutSeconds := // The amount of time in seconds to wait for the containers in a Docker Compose instance to start. Defaults to 500 seconds. 67 | composeRemoveTempFileOnShutdown := // True if a Docker Compose should remove the post Custom Tag processed Compose File on shutdown. This defaults to True. 68 | dockerMachineName := // If running on OSX the name of the Docker Machine Virtual machine being used. If not overridden it is set to 'default' 69 | dockerImageCreationTask := // The sbt task used to create a Docker image. For sbt-docker this should be set to 'docker.value' for the sbt-native-packager this should be set to '(publishLocal in Docker).value'. 70 | suppressColorFormatting := // True to suppress all color formatting in the output from the plugin. This defaults to the value of the 'sbt.log.noformat' property. If you are using `sbt-extras`, the use of the command line switch `-no-colors` will set this to True. 71 | testTagsToExecute := // Set of ScalaTest Tags to execute when dockerComposeTest is run. Separate multiple tags by a comma. It defaults to executing all tests. 72 | testExecutionArgs := // Additional ScalaTest Runner argument options to pass into the test runner. For example, this can be used for the generation of test reports. 73 | testExecutionExtraConfigTask := // An sbt task that returns a Map[String,String] of variables to pass into the ScalaTest Runner ConfigMap (in addition to standard service/port mappings). 74 | testDependenciesClasspath := // The path to all managed and unmanaged Test and Compile dependencies. This path needs to include the ScalaTest Jar for the tests to execute. This defaults to all managedClasspath and unmanagedClasspath in the Test and fullClasspath in the Compile Scope. 75 | testCasesJar := // The path to the Jar file containing the tests to execute. This defaults to the Jar file with the tests from the current sbt project. 76 | testCasesPackageTask := // The sbt Task to package the test cases used when running 'dockerComposeTest'. This defaults to the 'packageBin' task in the 'Test' Scope. 77 | testPassUseSpecs2 := // True if Specs2 is to be used to execute the test pass. This defaults to False and ScalaTest is used. 78 | testPassUseCucumber := // True if cucumber is to be used to execute the test pass. This defaults to False and ScalaTest is used. 79 | variablesForSubstitution := // A Map[String,String] of variables to substitute in your docker-compose file. These are substituted substituted by the plugin and not using environment variables. 80 | variablesForSubstitutionTask := // An sbt task that returns a Map[String,String] of variables to substitute in your docker-compose file. These are substituted by the plugin and not using environment variables. 81 | ``` 82 | There are several sample projects showing how to configure sbt-docker-compose that can be found in the [**examples**](examples) folder. 83 | 84 | To Start a Docker Compose Instance for Testing / Debugging 85 | ---------------------------------------------------------- 86 | 1) To start a new instance from the project with the Plugin enabled run: 87 | ``` 88 | dockerComposeUp 89 | ``` 90 | 91 | To use locally built images for all services defined in the Docker Compose file instead of pulling from the Docker 92 | Registry use the following command: 93 | ``` 94 | dockerComposeUp skipPull 95 | ``` 96 | 97 | 98 | By default before starting a Docker Compose instance a new Docker image will be built with your latest code changes. 99 | If you know you didn't make any code changes and do not want to build a new image use the 'skipBuild' argument: 100 | ``` 101 | dockerComposeUp skipBuild 102 | ``` 103 | 104 | You can start multiple compose instances on the same project as the plugin generates a unique name for each instance. 105 | 106 | When making frequent code changes on your local machine it is often useful to temporarily have the external ports remain the same. 107 | Use the '-useStaticPorts' argument to enable this functionality: 108 | 109 | ``` 110 | dockerComposeUp -useStaticPorts 111 | 112 | E.g. A port mapping of "0:3306" defined in the Compose file would be treated as "3306:3306" if this argument is supplied. 113 | ``` 114 | 115 | 2) To shutdown all instances started from the current project with the Plugin enabled run: 116 | ``` 117 | dockerComposeStop 118 | ``` 119 | To shutdown a specific instance regardless of the sbt project it belongs to run: 120 | ``` 121 | dockerComposeStop 122 | ``` 123 | 3) To restart a particular instance from the project with the Plugin enabled run: 124 | ``` 125 | dockerComposeRestart 126 | ``` 127 | You can also supply the 'skipPull', 'skipBuild' or '-useStaticPorts' argument as you would for the 'dockerComposeUp' command: 128 | ``` 129 | dockerComposeRestart [skipPull or skipBuild] [-useStaticPorts] 130 | ``` 131 | If there is only one running instance from the current sbt project the Instance Id is not required: 132 | ``` 133 | dockerComposeRestart 134 | ``` 135 | If there is no running instances from the current sbt project this command will start a new instance from the project. 136 | 137 | 4) To display the service connection information for each running Docker Compose instance run: 138 | ``` 139 | dockerComposeInstances 140 | ``` 141 | Instance information is persisted in a temporary file so that it will be available between restarts of an sbt session. 142 | 143 | 5) You can use tab completion to list out all of the arguments for each command shown above. 144 | 145 | To Execute ScalaTest Test Cases Against a Running Instance 146 | ---------------------------------------------------------- 147 | The sbt-docker-compose plugin provides the ability to run a suite of ScalaTest test cases against a Docker Compose instance. 148 | The dynamically assigned host and port information are passed into each test case via the 149 | ScalaTest [ConfigMap](http://doc.scalatest.org/2.0/index.html#org.scalatest.ConfigMap). 150 | 151 | The key into the map is the "serviceName:containerPort" (e.g. "basic:8080") that is statically defined in the Docker Compose file and it 152 | will return "host:hostPort" which is the Docker Compose generated and exposed endpoint that can be connected to at runtime 153 | for testing. There is also the key "serviceName:containerId" (e.g. "basic:containerId") which maps to the docker container id. 154 | 155 | The same ConfigMap key/value pairs are also available in the underlying JVM as system properties. For example, 156 | System.getProperty("serviceName:containerPort"), which is useful when the application uses system properties to set configuration 157 | during boot, which is a common pattern in Play Framework using TypeSafe's ConfigFactory. 158 | 159 | See the [**basic-with-tests**](examples/basic-with-tests) example for more details. 160 | 161 | By default all tests will be executed, however you can also [Tag](http://www.scalatest.org/user_guide/tagging_your_tests) 162 | test cases and indicate to the plugin to only execute those tests: 163 | ``` 164 | testTagsToExecute := "DockerComposeTag" 165 | ``` 166 | 1) To start a new DockerCompose instance, run your tests and then shut it down run: 167 | ``` 168 | dockerComposeTest 169 | ``` 170 | 2) To run your test cases against an already running instance execute: 171 | ``` 172 | dockerComposeTest 173 | ``` 174 | 3) To override the sbt setting 'testTagsToExecute' when starting a test pass provide a comma separated list of tags to 175 | the "-tags" argument: 176 | ``` 177 | dockerComposeTest -tags: 178 | ``` 179 | 180 | If running dockerComposeTest from outside of sbt you will need to quote the input so that the parameters are properly interpreted: 181 | ``` 182 | sbt 'dockerComposeTest -tags:' 183 | ``` 184 | 185 | 4) To attach a debugger during test case execution provide a port number to the "-debug" argument. This will suspend the 186 | tests from running until you attach a debugger to the specified port. For example: 187 | ``` 188 | dockerComposeTest -debug: 189 | ``` 190 | **Note:** The test pass is started using the using the 'java' process that exists on your command line PATH to launch the 191 | [ScalaTest Test Runner](http://www.scalatest.org/user_guide/using_the_runner). For this to work the classpath of your 192 | project needs to be built with the version of scala used by the project. If this is not configured correctly you may see 193 | an issue with the Test Runner failing to load classes. 194 | 195 | There is also support for running [Specs2](https://etorreborre.github.io/specs2/) test cases. For these tests only the 196 | JVM system properties will be available for accessing information about the Docker Compose instance under test. 197 | 198 | See the [**basic-with-tests-specs2**](examples/basic-with-tests-specs2) example for more details. 199 | 200 | Docker Compose File Custom Tags 201 | ------------------------------- 202 | Custom tags are a feature of the sbt-docker-compose plugin that add the ability to pre-process the contents of the 203 | docker-compose.yml file and make modifications to it before using it to start your instance. There are two custom tags 204 | for images that this plugin supports: "\" and "\". Support for additional tags can be added by 205 | overriding the "processCustomTags" method. 206 | 207 | 1) Define "\" on a set of particular images in the docker-compose file that you want to use a local copy of 208 | instead of pulling the latest available from the Docker Registry. 209 | ``` 210 | image: yourregistry.com/service:1.0.0 211 | ``` 212 | 2) Define "\" to launch the locally built version of the image instead of pulling from the public Docker 213 | Registry. This is how associated images from multi-project builds should be tagged. 214 | See the [**multi-project**](examples/multi-project) example. 215 | ``` 216 | image: service:latest 217 | ``` 218 | Instance Connection Information 219 | ------------------------------- 220 | After launching an instance with a dockerComposeUp command a table like the one below will be output with the set of endpoints 221 | that can be used to connect to the instance. The 'Host:Port' column contains the endpoints that are externally exposed. 222 | ``` 223 | +---------+----------------------+-------------+--------------+----------------+--------------+---------+ 224 | | Service | Host:Port | Tag Version | Image Source | Container Port | Container Id | IsDebug | 225 | +=========+======================+=============+==============+================+==============+=========+ 226 | | sample1 | 192.168.99.100:32973 | latest | build | 5005 | 0a43860a47a8 | YES | 227 | | sample2 | 192.168.99.100:32974 | latest | build | 5005 | 54803f8a6938 | YES | 228 | +---------+----------------------+-------------+--------------+----------------+--------------+---------+ 229 | ``` 230 | The 'Image Source' column can be one of the following values: 231 | 232 | 1) **defined**: The image tag is hardcoded in the compose file. For example: 233 | ``` 234 | image: service:latest 235 | 236 | image: yourregistry.com/service:1:0:0 237 | ``` 238 | 2) **build**: The image was built when starting the topology instance or the image is tagged as "\" and 239 | being used in a muti-project sbt compose-file. 240 | ``` 241 | image: service:latest 242 | ``` 243 | 3) **cache**: The locally cached version of the image is being used even if there may be a new version in the Docker 244 | Registry. This is the result of "\" being defined on a particular image or being passed to dockerComposeUp. 245 | ``` 246 | image: service:latest 247 | ``` 248 | Each running instance will also output the commands that can be used to: 249 | 250 | 1) **Stop the running instance.** For example: 251 | ``` 252 | dockerComposeStop 449342 253 | ``` 254 | 2) **Open a command shell to the container instance:** 255 | ``` 256 | docker exec -it bash 257 | ``` 258 | 3) **View the standard out logging from the instance:** 259 | ``` 260 | docker-compose -p 449342 -f /tmp/compose-updated4937097142223953047.yml logs 261 | ``` 262 | 4) **Execute test cases against the running instance:** 263 | ``` 264 | dockerComposeTest 265 | ``` 266 | 267 | Debugging 268 | --------- 269 | To debug a Docker Compose Java process edit your docker-compose.yml file to set the JAVA_TOOL_OPTIONS environment variable. 270 | In the ports section you will also need to expose the port value defined in the "address=" parameter. 271 | ``` 272 | environment: 273 | JAVA_TOOL_OPTIONS: -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=5005 274 | ports: 275 | - "0:5005" 276 | ``` 277 | Once the container is started you can to attach to it remotely. The instance connection table will mark 'YES' in the 'IsDebug' 278 | column for any exposed ports that can be attached to with the debugger. 279 | See the [basic](examples/basic-with-tests/docker/docker-compose.yml) example for a project configured with debugging enabled. 280 | 281 | See the test case execution section above for information on how to attach a debugger to running test cases. 282 | 283 | Examples 284 | -------- 285 | In the [**examples**](examples) folder there are six different projects showing different uses for the 286 | sbt-docker-compose plugin. 287 | 288 | 1) [**basic-with-tests**](examples/basic-with-tests): This project outlines a very basic example of how to enable the 289 | plugin on a simple application that will echo back "Hello, World!". The examples also shows how to create a ScalaTest 290 | test case that can run against the dynamically assigned endpoints. From sbt run the following to compile the code, 291 | build a Docker image and launch a Docker Compose instance. 292 | ``` 293 | dockerComposeUp 294 | ``` 295 | Run the following to execute a test case against the running instance: 296 | ``` 297 | dockerComposeTest 298 | ``` 299 | Run the following to start a new instance, run tests and shutdown the instance: 300 | ``` 301 | dockerComposeTest 302 | ``` 303 | Note how this example project shows how the testExecutionArgs setting can be used to create an html test pass report by 304 | by providing additional ScalaTest Runner defined [arguments](http://www.scalatest.org/user_guide/using_the_runner). 305 | ``` 306 | //Specify that an html report should be created for the test pass 307 | testExecutionArgs := "-h target/htmldir" 308 | ``` 309 | 2) [**basic-native-packager**](examples/basic-native-packager): This project outlines a very basic example of how to 310 | enable the plugin on a simple application. From sbt run the following to compile the code, build a Docker image and 311 | launch a Docker Compose instance. In this example the sbt-native-packager is used to build the Docker image instead of 312 | sbt-docker. 313 | ``` 314 | dockerComposeUp 315 | ``` 316 | 3) [**no-build**](examples/no-build): This project shows how sbt-docker-compose can be used to launch instances of 317 | images that are already published and do not need to be built locally. This example uses the official Redis image 318 | from Docker Hub. Once the instance is started Redis will be available on the displayed "Host:Port". The port is 319 | dynamically assigned so that multiple instances can be started. 320 | ``` 321 | dockerComposeUp 322 | ``` 323 | 4) [**multi-project**](examples/multi-project): This project shows how more advanced multi-project builds are supported. 324 | From sbt you can build the Docker image and launch a running instance of a single project by executing: 325 | ``` 326 | project sample1 327 | dockerComposeUp 328 | ``` 329 | However, from the root "multi-project" you can run the following to build new Docker images for both sub projects and 330 | launch a running instance that consists of both images: 331 | ``` 332 | project multi-project 333 | dockerComposeUp 334 | ``` 335 | Note how the docker-compose.yml file for the root project tags each image with "\". This allows dockerComposeUp 336 | to know that these images should not be updated from the Docker Registry. 337 | 338 | 5) [**basic-variable-substitution**](examples/basic-variable-substitution): This project demonstrates how you can re-use your 339 | existing docker-compose.yml with [variable substitution](https://docs.docker.com/compose/compose-file/#variable-substitution) 340 | using sbt-docker-compose. Instead of passing your variables as environment variables you can define them in your build.sbt 341 | programmatically. 342 | 343 | build.sbt: 344 | ``` 345 | variablesForSubstitution := Map("SOURCE_PORT" -> "5555") 346 | 347 | or 348 | 349 | variablesForSubstitutionTask := { /* code */ Map("SOURCE_PORT" -> "5555") } 350 | ``` 351 | docker-compose.yml: 352 | ``` 353 | basic: 354 | image: basic:1.0.0 355 | environment: 356 | JAVA_TOOL_OPTIONS: -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=5005 357 | ports: 358 | - "${SOURCE_PORT}:5005" 359 | ``` 360 | 6) [**basic-with-tests-integration**](examples/basic-with-tests-integration): This project shows how to change the default sbt Scope of the 361 | tests being executed from 'Test' to 'IntegrationTest' when 'dockerComposeTest' is run. 362 | 363 | 7) [**basic-with-tests-spec2**](examples/basic-with-tests-specs2): This project shows how to execute Specs2 based test cases via the 'specs2.files' runner 364 | by setting the following property in build.sbt: 365 | 366 | ``` 367 | testPassUseSpecs2 := true 368 | ``` 369 | 370 | Additionally, you can override the default Specs2 file runner properties as follows: 371 | 372 | ``` 373 | testExecutionExtraConfigTask := Map("filesrunner.verbose" -> "true") 374 | ``` 375 | 376 | 8) [**basic-with-tests-cucumber**](examples/basic-with-tests-cucumber): This project shows how to execute cucumber based test cases 377 | by setting the following property in build.sbt: 378 | 379 | ``` 380 | testPassUseCucumber := true 381 | ``` 382 | 383 | 384 | Currently Unsupported Docker Compose Fields 385 | ------------------------------------------- 386 | 1) "build:" - All docker compose services need to specify an "image:" field. 387 | 388 | 2) "container_name:" - To allow for multi-instance support container names need to be dynamically provided by the plugin 389 | instead of being explicitly defined. 390 | 391 | 3) "extends:" - All docker services must be defined in a single docker compose yml file. 392 | 393 | Other 394 | ----- 395 | Testing of sbt-docker-compose has been performed starting with docker-compose version: 1.5.1 396 | -------------------------------------------------------------------------------- /build.sbt: -------------------------------------------------------------------------------- 1 | import sbtrelease._ 2 | import ReleasePlugin._ 3 | import ReleaseStateTransformations._ 4 | 5 | sbtPlugin := true 6 | 7 | name := "sbt-docker-compose" 8 | 9 | organization := "com.tapad" 10 | 11 | scalaVersion := "2.10.6" 12 | 13 | crossSbtVersions := Seq("0.13.16", "1.0.0") 14 | 15 | libraryDependencies += { 16 | val liftJsonVersion = CrossVersion.partialVersion(scalaVersion.value) match { 17 | case Some((2, n)) if n < 12 => "2.5.4" 18 | case _ => "3.0.1" 19 | } 20 | "net.liftweb" %% "lift-json" % liftJsonVersion 21 | } 22 | 23 | libraryDependencies ++= Seq( 24 | "org.yaml" % "snakeyaml" % "1.15", 25 | "org.scalatest" %% "scalatest" % "3.0.1" % "test", 26 | "org.mockito" % "mockito-all" % "1.9.5" % "test") 27 | 28 | publishTo := { 29 | val nexus = "https://oss.sonatype.org" 30 | if (isSnapshot.value) 31 | Some("snapshots" at s"$nexus/content/repositories/snapshots") 32 | else 33 | Some("releases" at s"$nexus/service/local/staging/deploy/maven2") 34 | } 35 | 36 | useGpg := true 37 | 38 | publishMavenStyle := true 39 | 40 | publishArtifact in Test := false 41 | 42 | pomIncludeRepository := { _ => false } 43 | 44 | pomExtra := { 45 | http://github.com/Tapad/sbt-docker-compose 46 | 47 | 48 | BSD-style 49 | http://opensource.org/licenses/BSD-3-Clause 50 | 51 | 52 | 53 | git@github.com:Tapad/sbt-docker-compose.git 54 | scm:git:git@github.com:Tapad/sbt-docker-compose.git 55 | 56 | 57 | 58 | kurt.kopchik@tapad.com 59 | Kurt Kopchik 60 | http://github.com/kurtkopchik 61 | 62 | 63 | } 64 | 65 | scalariformSettings 66 | 67 | releaseNextVersion := { (version: String) => Version(version).map(_.bumpBugfix.asSnapshot.string).getOrElse(versionFormatError) } 68 | 69 | releaseProcess := Seq( 70 | checkSnapshotDependencies, 71 | inquireVersions, 72 | releaseStepCommandAndRemaining("^test"), 73 | setReleaseVersion, 74 | commitReleaseVersion, 75 | tagRelease, 76 | releaseStepCommandAndRemaining("^publishSigned"), 77 | setNextVersion, 78 | commitNextVersion, 79 | ReleaseStep(action = Command.process("sonatypeReleaseAll", _)), 80 | pushChanges 81 | ) 82 | -------------------------------------------------------------------------------- /examples/basic-native-packager/build.sbt: -------------------------------------------------------------------------------- 1 | name := "basic" 2 | 3 | version := "1.0.0" 4 | 5 | scalaVersion := "2.10.6" 6 | 7 | enablePlugins(JavaAppPackaging, DockerComposePlugin) 8 | 9 | dockerImageCreationTask := (publishLocal in Docker).value -------------------------------------------------------------------------------- /examples/basic-native-packager/docker/docker-compose.yml: -------------------------------------------------------------------------------- 1 | basic: 2 | image: basic:1.0.0 3 | environment: 4 | JAVA_TOOL_OPTIONS: -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=5005 5 | ports: 6 | - "0:5005" -------------------------------------------------------------------------------- /examples/basic-native-packager/project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version=0.13.16 -------------------------------------------------------------------------------- /examples/basic-native-packager/project/plugins.sbt: -------------------------------------------------------------------------------- 1 | addSbtPlugin("com.typesafe.sbt" %% "sbt-native-packager" % "1.1.0") 2 | 3 | addSbtPlugin("com.tapad" % "sbt-docker-compose" % "1.0.34") -------------------------------------------------------------------------------- /examples/basic-native-packager/src/main/scala/BasicApp.scala: -------------------------------------------------------------------------------- 1 | import scala.Console._ 2 | import scala.concurrent.duration._ 3 | 4 | object BasicApp extends App { 5 | println("Application started....") 6 | 7 | val deadline = 1.hour.fromNow 8 | do { 9 | println(s"Running application. Seconds left until showdown: ${deadline.timeLeft.toSeconds}") 10 | Thread.sleep(1000) 11 | } while (deadline.hasTimeLeft()) 12 | 13 | println("Application shutting down....") 14 | } -------------------------------------------------------------------------------- /examples/basic-variable-substitution/build.sbt: -------------------------------------------------------------------------------- 1 | name := "basic" 2 | 3 | version := "1.0.0" 4 | 5 | scalaVersion := "2.10.6" 6 | 7 | enablePlugins(JavaAppPackaging, DockerComposePlugin) 8 | 9 | dockerImageCreationTask := (publishLocal in Docker).value 10 | 11 | variablesForSubstitution := Map("SOURCE_PORT" -> "5555") -------------------------------------------------------------------------------- /examples/basic-variable-substitution/docker/docker-compose.yml: -------------------------------------------------------------------------------- 1 | basic: 2 | image: basic:1.0.0 3 | environment: 4 | JAVA_TOOL_OPTIONS: -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=5005 5 | ports: 6 | - "${SOURCE_PORT}:5005" -------------------------------------------------------------------------------- /examples/basic-variable-substitution/project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version=0.13.16 -------------------------------------------------------------------------------- /examples/basic-variable-substitution/project/plugins.sbt: -------------------------------------------------------------------------------- 1 | addSbtPlugin("com.typesafe.sbt" %% "sbt-native-packager" % "1.1.0") 2 | 3 | addSbtPlugin("com.tapad" % "sbt-docker-compose" % "1.0.34") -------------------------------------------------------------------------------- /examples/basic-variable-substitution/src/main/scala/BasicApp.scala: -------------------------------------------------------------------------------- 1 | import scala.Console._ 2 | import scala.concurrent.duration._ 3 | 4 | object BasicApp extends App { 5 | println("Application started....") 6 | 7 | val deadline = 1.hour.fromNow 8 | do { 9 | println(s"Running application. Seconds left until showdown: ${deadline.timeLeft.toSeconds}") 10 | Thread.sleep(1000) 11 | } while (deadline.hasTimeLeft()) 12 | 13 | println("Application shutting down....") 14 | } -------------------------------------------------------------------------------- /examples/basic-with-tests-cucumber/build.sbt: -------------------------------------------------------------------------------- 1 | name := "basic-cucumber" 2 | 3 | version := "1.0.0" 4 | 5 | scalaVersion := "2.11.11" 6 | 7 | enablePlugins(DockerPlugin, DockerComposePlugin, CucumberPlugin) 8 | 9 | libraryDependencies ++= { 10 | val cucumber = List("core", "jvm", "junit").map(suffix => 11 | "info.cukes" % s"cucumber-$suffix" % "1.2.5" % "test") :+ ("info.cukes" %% "cucumber-scala" % "1.2.5" % "test") 12 | 13 | cucumber ::: List( 14 | "org.scalactic" %% "scalactic" % "3.0.4" % "test", 15 | "org.scalatest" %% "scalatest" % "3.0.4" % ("test->*"), 16 | "org.pegdown" % "pegdown" % "1.6.0" % ("test->*"), 17 | "junit" % "junit" % "4.12" % "test" 18 | ) 19 | } 20 | 21 | CucumberPlugin.glue := "classpath:" 22 | CucumberPlugin.features += "classpath:" 23 | 24 | //Set the image creation Task to be the one used by sbt-docker 25 | dockerImageCreationTask := docker.value 26 | 27 | testPassUseCucumber := true 28 | 29 | imageNames in docker := Seq(ImageName( 30 | repository = name.value.toLowerCase, 31 | tag = Some(version.value)) 32 | ) 33 | 34 | // create a docker file with a file /inputs/example.input 35 | dockerfile in docker := { 36 | 37 | val classpath: Classpath = (fullClasspath in Test).value 38 | sLog.value.debug(s"Classpath is ${classpath.files.mkString("\n")}\n") 39 | 40 | new Dockerfile { 41 | val dockerAppPath = "/app/" 42 | from("java") 43 | add(classpath.files, dockerAppPath) 44 | 45 | entryPoint("java", "-cp", s"$dockerAppPath:$dockerAppPath*", "example.CalculatorServer") 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /examples/basic-with-tests-cucumber/docker/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | basic-cucumber: 4 | image: basic-cucumber:1.0.0 5 | ports: 6 | - 8080:8080 -------------------------------------------------------------------------------- /examples/basic-with-tests-cucumber/project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version=0.13.16 -------------------------------------------------------------------------------- /examples/basic-with-tests-cucumber/project/plugins.sbt: -------------------------------------------------------------------------------- 1 | addSbtPlugin("se.marcuslonnberg" % "sbt-docker" % "1.4.1") 2 | addSbtPlugin("com.tapad" % "sbt-docker-compose" % "1.0.34") 3 | addSbtPlugin("com.waioeka.sbt" % "cucumber-plugin" % "0.1.2") -------------------------------------------------------------------------------- /examples/basic-with-tests-cucumber/src/main/scala/example/Calculator.scala: -------------------------------------------------------------------------------- 1 | package example 2 | 3 | import java.nio.file.{Files, Path, Paths} 4 | 5 | import scala.collection.JavaConverters._ 6 | 7 | object Calculator { 8 | 9 | def main(args: Array[String]): Unit = { 10 | args.toList match { 11 | case Nil => println(s"Usage: Expected either two ints or a file path") 12 | case List(filePath) => 13 | println(apply(Paths.get(filePath))) 14 | case List(a, b) => 15 | println(add(a.toInt, b.toInt)) 16 | case err => println(s"Usage: Expected either two ints or a file path, but got $err") 17 | } 18 | } 19 | 20 | def add(x: Int, y: Int): Int = x + y 21 | 22 | def subtract(x: Int, y: Int): Int = x - y 23 | 24 | val PlusR = """(\d+)\s*\+\s*(\d+)""".r 25 | val MinusR = """(\d+)\s*-\s*(\d+)""".r 26 | 27 | /** 28 | * Evaluates the first line of the input file to be an addition or subtration operation. 29 | * 30 | * This is just added to demonstrate e.g. composing two containers w/ shared volumes 31 | */ 32 | def apply(input: Path): Int = { 33 | Files.readAllLines(input).asScala.headOption match { 34 | case Some(PlusR(a, b)) => add(a.toInt, b.toInt) 35 | case Some(MinusR(a, b)) => subtract(a.toInt, b.toInt) 36 | case _ => sys.error("Whacha talkin' bout, willis?") 37 | } 38 | } 39 | 40 | } 41 | -------------------------------------------------------------------------------- /examples/basic-with-tests-cucumber/src/main/scala/example/CalculatorClient.scala: -------------------------------------------------------------------------------- 1 | package example 2 | 3 | import scala.sys.process._ 4 | 5 | case class CalculatorClient(hostPort: String) { 6 | 7 | def add(x: Int, y: Int) = curl("add", x, y) 8 | 9 | def subtract(x: Int, y: Int) = curl("subtract", x, y) 10 | 11 | private def curl(path: String, x: Int, y: Int) = { 12 | val url = s"${hostPort}/$path/$x/$y" 13 | println(s"curling $url") 14 | val output = s"curl $url".!! 15 | try { 16 | output.trim.toInt 17 | } catch{ 18 | case nfe : NumberFormatException => 19 | println(output) 20 | throw new Exception(s"$url responded with: >${output}< : $nfe", nfe) 21 | } 22 | } 23 | 24 | } 25 | -------------------------------------------------------------------------------- /examples/basic-with-tests-cucumber/src/main/scala/example/CalculatorServer.scala: -------------------------------------------------------------------------------- 1 | package example 2 | 3 | import java.net.InetSocketAddress 4 | 5 | import com.sun.net.httpserver.{HttpExchange, HttpHandler, HttpServer} 6 | 7 | object CalculatorServer extends App { 8 | def start(port: Int) = { 9 | val server = HttpServer.create(new InetSocketAddress(port), 0) 10 | server.createContext("/", Handler) 11 | server.start() 12 | server 13 | } 14 | 15 | 16 | object Handler extends HttpHandler { 17 | 18 | val AddInput = """/add/([-0-9]+)/([-0-9]+)/?""".r 19 | val SubtractInput = """/subtract/([-0-9]+)/([-0-9]+)/?""".r 20 | 21 | override def handle(ex: HttpExchange): Unit = { 22 | val path = ex.getRequestURI.toString 23 | 24 | val resultOpt = path match { 25 | case AddInput(a, b) => Option(Calculator.add(a.toInt, b.toInt)) 26 | case SubtractInput(a, b) => 27 | Option(Calculator.subtract(a.toInt, b.toInt)) 28 | case _ => None 29 | } 30 | 31 | val replyString = resultOpt match { 32 | case Some(x) => 33 | val response = x.toString 34 | ex.sendResponseHeaders(200, response.length) 35 | response 36 | case None => 37 | val response = s"Unknown path: $path" 38 | ex.sendResponseHeaders(404, response.length) 39 | response 40 | } 41 | 42 | ex.getResponseBody.write(replyString.getBytes) 43 | } 44 | } 45 | 46 | 47 | val port = args.headOption.map(_.toInt).getOrElse(8080) 48 | println(s"Starting calculator server on port $port w/ user args ${args.mkString(": [", ",", "]")}") 49 | start(port) 50 | } -------------------------------------------------------------------------------- /examples/basic-with-tests-cucumber/src/main/scala/example/ClientApp.scala: -------------------------------------------------------------------------------- 1 | package example 2 | 3 | object ClientApp extends App { 4 | val client = CalculatorClient("http://localhost:8080") 5 | 6 | println(client.add(5, 6)) 7 | println(client.subtract(5, 6)) 8 | 9 | } 10 | -------------------------------------------------------------------------------- /examples/basic-with-tests-cucumber/src/test/resources/calculator-service.feature: -------------------------------------------------------------------------------- 1 | Feature: Remote Server/Client contract 2 | 3 | Background: 4 | And a calculator client against http://localhost:8080 5 | 6 | Scenario Outline: Addition 7 | Given a remote request to add and 8 | Then The response should be 9 | Examples: 10 | | lhs | rhs | sum | 11 | | -1 | 1 | 0 | 12 | | 0 | 0 | 0 | 13 | | 1 | 0 | 1 | 14 | | 0 | 1 | 1 | 15 | | 1 | 2 | 3 | 16 | 17 | Scenario Outline: Subtraction 18 | Given a remote request to subtract from 19 | Then The response should be 20 | Examples: 21 | | lhs | rhs | result | 22 | | -1 | 1 | -2 | 23 | | 0 | 0 | 0 | 24 | | 1 | 0 | 1 | 25 | | 0 | 1 | -1 | 26 | | 1 | 2 | -1 | 27 | -------------------------------------------------------------------------------- /examples/basic-with-tests-cucumber/src/test/resources/calculator.feature: -------------------------------------------------------------------------------- 1 | Feature: Calculator Operations 2 | 3 | Scenario Outline: Addition 4 | Given + 5 | Then The result should be 6 | Examples: 7 | | lhs | rhs | sum | 8 | | -1 | 1 | 0 | 9 | | 0 | 0 | 0 | 10 | | 1 | 0 | 1 | 11 | | 0 | 1 | 1 | 12 | | 1 | 2 | 3 | 13 | 14 | Scenario Outline: Subtraction 15 | Given - 16 | Then The result should be 17 | Examples: 18 | | lhs | rhs | result | 19 | | -1 | 1 | -2 | 20 | | 0 | 0 | 0 | 21 | | 1 | 0 | 1 | 22 | | 0 | 1 | -1 | 23 | | 1 | 2 | -1 | 24 | -------------------------------------------------------------------------------- /examples/basic-with-tests-cucumber/src/test/scala/CalculatorSteps.scala: -------------------------------------------------------------------------------- 1 | package example 2 | 3 | import cucumber.api.scala.{EN, ScalaDsl} 4 | import org.scalatest.Matchers 5 | 6 | 7 | class CalculatorSteps extends ScalaDsl with EN with Matchers { 8 | 9 | var lastResult = Int.MinValue 10 | 11 | Given("""^(.+) \+ (.+)$""") { (lhs: Int, rhs: Int) => 12 | lastResult = Calculator.add(lhs, rhs) 13 | } 14 | Given("""^(.+) - (.+)$""") { (lhs: Int, rhs: Int) => 15 | lastResult = Calculator.subtract(lhs, rhs) 16 | } 17 | Then("""^The result should be ([-0-9]+)$""") { (expected: Int) => 18 | lastResult shouldBe expected 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /examples/basic-with-tests-cucumber/src/test/scala/CucumberTest.scala: -------------------------------------------------------------------------------- 1 | package example 2 | 3 | import cucumber.api.CucumberOptions 4 | import cucumber.api.junit.Cucumber 5 | import org.junit.runner.RunWith 6 | 7 | @RunWith(classOf[Cucumber]) 8 | @CucumberOptions( 9 | features = Array("classpath:"), 10 | glue = Array("classpath:"), 11 | plugin = Array("pretty", "html:target/cucumber/report.html"), 12 | strict = true 13 | ) 14 | class CucumberTest 15 | -------------------------------------------------------------------------------- /examples/basic-with-tests-cucumber/src/test/scala/ServiceSteps.scala: -------------------------------------------------------------------------------- 1 | package example 2 | 3 | import cucumber.api.scala.{EN, ScalaDsl} 4 | import org.scalatest.Matchers 5 | import org.scalatest.concurrent.{Eventually, ScalaFutures} 6 | 7 | object ServiceSteps { 8 | lazy val defaultStartedService = { 9 | CalculatorServer.start(8080) 10 | } 11 | } 12 | 13 | class ServiceSteps extends ScalaDsl with EN with Matchers with ScalaFutures with Eventually { 14 | 15 | var lastResult = Int.MinValue 16 | var client: CalculatorClient = null 17 | 18 | /** 19 | * This assumes a running service mapped against the host machine at the given location 20 | */ 21 | Given("""^a calculator client against (.+)$""") { hostPort: String => 22 | client = CalculatorClient(hostPort) 23 | 24 | // prove connectivity eagerly within this step 25 | client.add(0, 0) shouldBe 0 26 | } 27 | 28 | Given("""^a remote request to add (.+) and (.+)$""") { (lhs: Int, rhs: Int) => 29 | lastResult = client.add(lhs, rhs) 30 | } 31 | Given("""^a remote request to subtract (.+) from (.+)$""") { (rhs: Int, lhs: Int) => 32 | lastResult = client.subtract(lhs, rhs) 33 | } 34 | Then("""^The response should be ([-0-9]+)$""") { (expected: Int) => 35 | lastResult shouldBe expected 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /examples/basic-with-tests-integration/build.sbt: -------------------------------------------------------------------------------- 1 | import java.io.File 2 | 3 | name := "basic" 4 | 5 | version := "1.0.0" 6 | 7 | scalaVersion := "2.11.1" 8 | 9 | libraryDependencies ++= Seq("org.scalatest" %% "scalatest" % "3.0.1" % "it", 10 | "org.scalaj" %% "scalaj-http" % "2.2.1" % "it", 11 | "org.pegdown" % "pegdown" % "1.6.0" % "it" 12 | ) 13 | 14 | enablePlugins(DockerPlugin, DockerComposePlugin) 15 | 16 | Defaults.itSettings 17 | 18 | lazy val root = project.in(file(".")).configs(IntegrationTest) 19 | 20 | //To use 'dockerComposeTest' to run tests in the 'IntegrationTest' scope instead of the default 'Test' scope: 21 | // 1) Package the tests that exist in the IntegrationTest scope 22 | testCasesPackageTask := (sbt.Keys.packageBin in IntegrationTest).value 23 | // 2) Specify the path to the IntegrationTest jar produced in Step 1 24 | testCasesJar := artifactPath.in(IntegrationTest, packageBin).value.getAbsolutePath 25 | // 3) Include any IntegrationTest scoped resources on the classpath if they are used in the tests 26 | testDependenciesClasspath := { 27 | val fullClasspathCompile = (fullClasspath in Compile).value 28 | val classpathTestManaged = (managedClasspath in IntegrationTest).value 29 | val classpathTestUnmanaged = (unmanagedClasspath in IntegrationTest).value 30 | val testResources = (resources in IntegrationTest).value 31 | (fullClasspathCompile.files ++ classpathTestManaged.files ++ classpathTestUnmanaged.files ++ testResources).map(_.getAbsoluteFile).mkString(File.pathSeparator) 32 | } 33 | 34 | //Set the image creation Task to be the one used by sbt-docker 35 | dockerImageCreationTask := docker.value 36 | 37 | dockerfile in docker := { 38 | new Dockerfile { 39 | val dockerAppPath = "/app/" 40 | val mainClassString = (mainClass in Compile).value.get 41 | val classpath = (fullClasspath in Compile).value 42 | from("java") 43 | add(classpath.files, dockerAppPath) 44 | entryPoint("java", "-cp", s"$dockerAppPath:$dockerAppPath/*", s"$mainClassString") 45 | } 46 | } 47 | 48 | imageNames in docker := Seq(ImageName( 49 | repository = name.value.toLowerCase, 50 | tag = Some(version.value)) 51 | ) -------------------------------------------------------------------------------- /examples/basic-with-tests-integration/docker/docker-compose.yml: -------------------------------------------------------------------------------- 1 | basic: 2 | image: basic:1.0.0 3 | environment: 4 | JAVA_TOOL_OPTIONS: -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=5005 5 | ports: 6 | - "0:8080" 7 | - "0:5005" -------------------------------------------------------------------------------- /examples/basic-with-tests-integration/project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version=0.13.16 -------------------------------------------------------------------------------- /examples/basic-with-tests-integration/project/plugins.sbt: -------------------------------------------------------------------------------- 1 | addSbtPlugin("se.marcuslonnberg" % "sbt-docker" % "1.4.1") 2 | 3 | addSbtPlugin("com.tapad" % "sbt-docker-compose" % "1.0.34") 4 | -------------------------------------------------------------------------------- /examples/basic-with-tests-integration/src/it/scala/BasicAppSpec.scala: -------------------------------------------------------------------------------- 1 | import org.scalatest._ 2 | import scala.Console._ 3 | import scala.sys.process._ 4 | import scalaj.http.Http 5 | import org.scalatest.Tag 6 | import org.scalatest.concurrent._ 7 | import org.scalatest.exceptions._ 8 | import java.io.{ByteArrayOutputStream, PrintWriter} 9 | 10 | class BasicAppSpec extends fixture.FunSuite with fixture.ConfigMapFixture with Eventually with IntegrationPatience with Matchers { 11 | 12 | // The configMap passed to each test case will contain the connection information for the running Docker Compose 13 | // services. The key into the map is "serviceName:containerPort" and it will return "host:hostPort" which is the 14 | // Docker Compose generated endpoint that can be connected to at runtime. You can use this to endpoint connect to 15 | // for testing. Each service will also inject a "serviceName:containerId" key with the value equal to the container id. 16 | // You can use this to emulate service failures by killing and restarting the container. 17 | val basicServiceName = "basic" 18 | val basicServiceHostKey = s"$basicServiceName:8080" 19 | val basicServiceContainerIdKey = s"$basicServiceName:containerId" 20 | 21 | test("Validate that the Docker Compose endpoint returns a success code and the string 'Hello, World!'") { 22 | configMap =>{ 23 | println(configMap) 24 | val hostInfo = getHostInfo(configMap) 25 | val containerId = getContainerId(configMap) 26 | 27 | println(s"Attempting to connect to: $hostInfo, container id is $containerId") 28 | 29 | eventually { 30 | val output = Http(s"http://$hostInfo").asString 31 | output.isSuccess shouldBe true 32 | output.body should include ("Hello, World!") 33 | } 34 | } 35 | } 36 | 37 | test("Validate presence of docker config information in system properties") { 38 | configMap => 39 | Option(System.getProperty(basicServiceHostKey)) shouldBe defined 40 | } 41 | 42 | def getHostInfo(configMap: ConfigMap): String = getContainerSetting(configMap, basicServiceHostKey) 43 | def getContainerId(configMap: ConfigMap): String = getContainerSetting(configMap, basicServiceContainerIdKey) 44 | 45 | def getContainerSetting(configMap: ConfigMap, key: String): String = { 46 | if (configMap.keySet.contains(key)) { 47 | configMap(key).toString 48 | } 49 | else { 50 | throw new TestFailedException(s"Cannot find the expected Docker Compose service key '$key' in the configMap", 10) 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /examples/basic-with-tests-integration/src/main/scala/BasicApp.scala: -------------------------------------------------------------------------------- 1 | import java.io.PrintWriter 2 | import java.net.ServerSocket 3 | 4 | object BasicApp extends App { 5 | 6 | val text = 7 | """HTTP/1.0 200 OK 8 | Content-Type: text/html 9 | Content-Length: 200 10 | 11 | Hello, World!

Hello, World!

""" 12 | val port = 8080 13 | val listener = new ServerSocket(port) 14 | 15 | while (true) { 16 | val sock = listener.accept() 17 | new PrintWriter(sock.getOutputStream, true).println(text) 18 | sock.shutdownOutput() 19 | } 20 | } -------------------------------------------------------------------------------- /examples/basic-with-tests-specs2/build.sbt: -------------------------------------------------------------------------------- 1 | name := "basic" 2 | 3 | version := "1.0.0" 4 | 5 | scalaVersion := "2.11.1" 6 | 7 | libraryDependencies ++= Seq("org.specs2" %% "specs2-core" % "3.7.3" % "test", 8 | "org.scalaj" %% "scalaj-http" % "2.2.1" % "test", 9 | "org.pegdown" % "pegdown" % "1.6.0" % "test" 10 | ) 11 | 12 | enablePlugins(DockerPlugin, DockerComposePlugin) 13 | 14 | //Set the image creation Task to be the one used by sbt-docker 15 | dockerImageCreationTask := docker.value 16 | 17 | testPassUseSpecs2 := true 18 | 19 | testExecutionExtraConfigTask := Map("filesrunner.verbose" -> s"true") 20 | 21 | dockerfile in docker := { 22 | new Dockerfile { 23 | val dockerAppPath = "/app/" 24 | val mainClassString = (mainClass in Compile).value.get 25 | val classpath = (fullClasspath in Compile).value 26 | from("java") 27 | add(classpath.files, dockerAppPath) 28 | entryPoint("java", "-cp", s"$dockerAppPath:$dockerAppPath/*", s"$mainClassString") 29 | } 30 | } 31 | 32 | imageNames in docker := Seq(ImageName( 33 | repository = name.value.toLowerCase, 34 | tag = Some(version.value)) 35 | ) -------------------------------------------------------------------------------- /examples/basic-with-tests-specs2/docker/docker-compose.yml: -------------------------------------------------------------------------------- 1 | basic: 2 | image: basic:1.0.0 3 | environment: 4 | JAVA_TOOL_OPTIONS: -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=5005 5 | ports: 6 | - "0:8080" 7 | - "0:5005" -------------------------------------------------------------------------------- /examples/basic-with-tests-specs2/project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version=0.13.16 -------------------------------------------------------------------------------- /examples/basic-with-tests-specs2/project/plugins.sbt: -------------------------------------------------------------------------------- 1 | addSbtPlugin("se.marcuslonnberg" % "sbt-docker" % "1.4.1") 2 | 3 | addSbtPlugin("com.tapad" % "sbt-docker-compose" % "1.0.34") 4 | -------------------------------------------------------------------------------- /examples/basic-with-tests-specs2/src/main/scala/BasicApp.scala: -------------------------------------------------------------------------------- 1 | import java.io.PrintWriter 2 | import java.net.ServerSocket 3 | 4 | object BasicApp extends App { 5 | 6 | val text = 7 | """HTTP/1.0 200 OK 8 | Content-Type: text/html 9 | Content-Length: 200 10 | 11 | Hello, World!

Hello, World!

""" 12 | val port = 8080 13 | val listener = new ServerSocket(port) 14 | 15 | while (true) { 16 | val sock = listener.accept() 17 | new PrintWriter(sock.getOutputStream, true).println(text) 18 | sock.shutdownOutput() 19 | } 20 | } -------------------------------------------------------------------------------- /examples/basic-with-tests-specs2/src/test/scala/BasicAppSpec.scala: -------------------------------------------------------------------------------- 1 | import scala.Console._ 2 | import scala.sys.process._ 3 | import org.specs2._ 4 | import org.specs2.execute._ 5 | import scalaj.http.Http 6 | import java.io.{ByteArrayOutputStream, PrintWriter} 7 | 8 | class BasicAppSpec extends mutable.Specification { 9 | 10 | // The System Properties will contain the connection information for the running Docker Compose 11 | // services. The key into the map is "serviceName:containerPort" and it will return "host:hostPort" which is the 12 | // Docker Compose generated endpoint that can be connected to at runtime. You can use this to endpoint connect to 13 | // for testing. Each service will also inject a "serviceName:containerId" key with the value equal to the container id. 14 | // You can use this to emulate service failures by killing and restarting the container. 15 | val basicServiceName = "basic" 16 | val basicServiceHostKey = s"$basicServiceName:8080" 17 | val basicServiceContainerIdKey = s"$basicServiceName:containerId" 18 | val hostInfo = getHostInfo() 19 | val containerId = getContainerId() 20 | 21 | "Validate that the Docker Compose endpoint returns a success code and the string 'Hello, World!'" >> { 22 | println(s"Attempting to connect to: $hostInfo, container id is $containerId") 23 | 24 | eventually { 25 | val output = Http(s"http://$hostInfo").asString 26 | output.isSuccess mustEqual true 27 | output.body must contain ("Hello, World!") 28 | } 29 | } 30 | 31 | def getHostInfo(): String = getContainerSetting(basicServiceHostKey) 32 | def getContainerId(): String = getContainerSetting(basicServiceContainerIdKey) 33 | 34 | def getContainerSetting(key: String): String = { 35 | if (System.getProperty(key) != null) { 36 | System.getProperty(key) 37 | } 38 | else { 39 | throw new FailureException(Failure(s"Cannot find the expected Docker Compose service key '$key' in the System Properties")) 40 | } 41 | } 42 | } -------------------------------------------------------------------------------- /examples/basic-with-tests/build.sbt: -------------------------------------------------------------------------------- 1 | name := "basic" 2 | 3 | version := "1.0.0" 4 | 5 | scalaVersion := "2.11.1" 6 | 7 | libraryDependencies ++= Seq("org.scalatest" %% "scalatest" % "3.0.1" % "test", 8 | "org.scalaj" %% "scalaj-http" % "2.2.1" % "test", 9 | "org.pegdown" % "pegdown" % "1.6.0" % "test" 10 | ) 11 | 12 | enablePlugins(DockerPlugin, DockerComposePlugin) 13 | 14 | //Only execute tests tagged as the following 15 | testTagsToExecute := "DockerComposeTag" 16 | 17 | //Specify that an html report should be created for the test pass 18 | testExecutionArgs := "-h target/htmldir" 19 | 20 | //Set the image creation Task to be the one used by sbt-docker 21 | dockerImageCreationTask := docker.value 22 | 23 | dockerfile in docker := { 24 | new Dockerfile { 25 | val dockerAppPath = "/app/" 26 | val mainClassString = (mainClass in Compile).value.get 27 | val classpath = (fullClasspath in Compile).value 28 | from("java") 29 | add(classpath.files, dockerAppPath) 30 | entryPoint("java", "-cp", s"$dockerAppPath:$dockerAppPath/*", s"$mainClassString") 31 | } 32 | } 33 | 34 | imageNames in docker := Seq(ImageName( 35 | repository = name.value.toLowerCase, 36 | tag = Some(version.value)) 37 | ) -------------------------------------------------------------------------------- /examples/basic-with-tests/docker/docker-compose.yml: -------------------------------------------------------------------------------- 1 | basic: 2 | image: basic:1.0.0 3 | environment: 4 | JAVA_TOOL_OPTIONS: -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=5005 5 | ports: 6 | - "0:8080" 7 | - "0:5005" -------------------------------------------------------------------------------- /examples/basic-with-tests/project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version=0.13.16 -------------------------------------------------------------------------------- /examples/basic-with-tests/project/plugins.sbt: -------------------------------------------------------------------------------- 1 | addSbtPlugin("se.marcuslonnberg" % "sbt-docker" % "1.4.1") 2 | 3 | addSbtPlugin("com.tapad" % "sbt-docker-compose" % "1.0.34") 4 | -------------------------------------------------------------------------------- /examples/basic-with-tests/src/main/scala/BasicApp.scala: -------------------------------------------------------------------------------- 1 | import java.io.PrintWriter 2 | import java.net.ServerSocket 3 | 4 | object BasicApp extends App { 5 | 6 | val text = 7 | """HTTP/1.0 200 OK 8 | Content-Type: text/html 9 | Content-Length: 200 10 | 11 | Hello, World!

Hello, World!

""" 12 | val port = 8080 13 | val listener = new ServerSocket(port) 14 | 15 | while (true) { 16 | val sock = listener.accept() 17 | new PrintWriter(sock.getOutputStream, true).println(text) 18 | sock.shutdownOutput() 19 | } 20 | } -------------------------------------------------------------------------------- /examples/basic-with-tests/src/test/scala/BasicAppSpec.scala: -------------------------------------------------------------------------------- 1 | import org.scalatest._ 2 | import scala.Console._ 3 | import scala.sys.process._ 4 | import scalaj.http.Http 5 | import org.scalatest.Tag 6 | import org.scalatest.concurrent._ 7 | import org.scalatest.exceptions._ 8 | import java.io.{ByteArrayOutputStream, PrintWriter} 9 | 10 | //You can define a specific tag to indicate which test should be run against the Docker Compose instance 11 | object DockerComposeTag extends Tag("DockerComposeTag") 12 | 13 | class BasicAppSpec extends fixture.FunSuite with fixture.ConfigMapFixture with Eventually with IntegrationPatience with Matchers { 14 | 15 | // The configMap passed to each test case will contain the connection information for the running Docker Compose 16 | // services. The key into the map is "serviceName:containerPort" and it will return "host:hostPort" which is the 17 | // Docker Compose generated endpoint that can be connected to at runtime. You can use this to endpoint connect to 18 | // for testing. Each service will also inject a "serviceName:containerId" key with the value equal to the container id. 19 | // You can use this to emulate service failures by killing and restarting the container. 20 | val basicServiceName = "basic" 21 | val basicServiceHostKey = s"$basicServiceName:8080" 22 | val basicServiceContainerIdKey = s"$basicServiceName:containerId" 23 | 24 | test("Validate that the Docker Compose endpoint returns a success code and the string 'Hello, World!'", DockerComposeTag) { 25 | configMap =>{ 26 | println(configMap) 27 | val hostInfo = getHostInfo(configMap) 28 | val containerId = getContainerId(configMap) 29 | 30 | println(s"Attempting to connect to: $hostInfo, container id is $containerId") 31 | 32 | eventually { 33 | val output = Http(s"http://$hostInfo").asString 34 | output.isSuccess shouldBe true 35 | output.body should include ("Hello, World!") 36 | } 37 | } 38 | } 39 | 40 | test("Example Untagged Test. Will not be run.") { 41 | configMap => 42 | } 43 | 44 | test("Validate presence of docker config information in system properties", DockerComposeTag) { 45 | configMap => 46 | Option(System.getProperty(basicServiceHostKey)) shouldBe defined 47 | } 48 | 49 | def getHostInfo(configMap: ConfigMap): String = getContainerSetting(configMap, basicServiceHostKey) 50 | def getContainerId(configMap: ConfigMap): String = getContainerSetting(configMap, basicServiceContainerIdKey) 51 | 52 | def getContainerSetting(configMap: ConfigMap, key: String): String = { 53 | if (configMap.keySet.contains(key)) { 54 | configMap(key).toString 55 | } 56 | else { 57 | throw new TestFailedException(s"Cannot find the expected Docker Compose service key '$key' in the configMap", 10) 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /examples/multi-project/build.sbt: -------------------------------------------------------------------------------- 1 | name := "multi-project" 2 | 3 | version := "1.0.0" 4 | 5 | scalaVersion := "2.10.6" 6 | 7 | enablePlugins(DockerComposePlugin) 8 | 9 | docker <<= (docker in sample1, docker in sample2) map {(image, _) => image} 10 | 11 | dockerImageCreationTask := docker.value 12 | 13 | val dockerAppPath = "/app/" 14 | 15 | lazy val sample1 = project. 16 | enablePlugins(sbtdocker.DockerPlugin, DockerComposePlugin). 17 | settings( 18 | dockerfile in docker := { 19 | new Dockerfile { 20 | val mainClassString = (mainClass in Compile).value.get 21 | val classpath = (fullClasspath in Compile).value 22 | from("java") 23 | add(classpath.files, dockerAppPath) 24 | entryPoint("java", "-cp", s"$dockerAppPath:$dockerAppPath/*", s"$mainClassString") 25 | } 26 | }, 27 | imageNames in docker := Seq(ImageName( 28 | repository = name.value.toLowerCase, 29 | tag = Some("latest")) 30 | ) 31 | ) 32 | 33 | lazy val sample2 = project. 34 | enablePlugins(sbtdocker.DockerPlugin, DockerComposePlugin). 35 | settings( 36 | dockerfile in docker := { 37 | new Dockerfile { 38 | val mainClassString = (mainClass in Compile).value.get 39 | val classpath = (fullClasspath in Compile).value 40 | from("java") 41 | add(classpath.files, dockerAppPath) 42 | entryPoint("java", "-cp", s"$dockerAppPath:$dockerAppPath/*", s"$mainClassString") 43 | } 44 | }, 45 | imageNames in docker := Seq(ImageName( 46 | repository = name.value.toLowerCase, 47 | tag = Some("latest")) 48 | ) 49 | ) -------------------------------------------------------------------------------- /examples/multi-project/docker/docker-compose.yml: -------------------------------------------------------------------------------- 1 | sample1: 2 | image: sample1:latest 3 | environment: 4 | JAVA_TOOL_OPTIONS: -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=5005 5 | ports: 6 | - "0:5005" 7 | sample2: 8 | image: sample2:latest 9 | environment: 10 | JAVA_TOOL_OPTIONS: -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=5005 11 | ports: 12 | - "0:5005" -------------------------------------------------------------------------------- /examples/multi-project/project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version=0.13.16 -------------------------------------------------------------------------------- /examples/multi-project/project/plugins.sbt: -------------------------------------------------------------------------------- 1 | addSbtPlugin("se.marcuslonnberg" % "sbt-docker" % "1.4.1") 2 | 3 | addSbtPlugin("com.tapad" % "sbt-docker-compose" % "1.0.34") -------------------------------------------------------------------------------- /examples/multi-project/sample1/docker/docker-compose.yml: -------------------------------------------------------------------------------- 1 | sample1: 2 | image: sample1:latest 3 | environment: 4 | JAVA_TOOL_OPTIONS: -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=5005 5 | ports: 6 | - "0:5005" -------------------------------------------------------------------------------- /examples/multi-project/sample1/src/main/scala/BasicApp.scala: -------------------------------------------------------------------------------- 1 | import scala.Console._ 2 | import scala.concurrent.duration._ 3 | 4 | object BasicApp extends App { 5 | println("Application started....") 6 | 7 | val deadline = 1.hour.fromNow 8 | do { 9 | println(s"Running application. Seconds left until showdown: ${deadline.timeLeft.toSeconds}") 10 | Thread.sleep(1000) 11 | } while (deadline.hasTimeLeft()) 12 | 13 | println("Application shutting down....") 14 | } -------------------------------------------------------------------------------- /examples/multi-project/sample2/docker/docker-compose.yml: -------------------------------------------------------------------------------- 1 | sample2: 2 | image: sample2:latest 3 | environment: 4 | JAVA_TOOL_OPTIONS: -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=5005 5 | ports: 6 | - "0:5005" -------------------------------------------------------------------------------- /examples/multi-project/sample2/src/main/scala/BasicApp.scala: -------------------------------------------------------------------------------- 1 | import scala.Console._ 2 | import scala.concurrent.duration._ 3 | 4 | object BasicApp extends App { 5 | println("Application started....") 6 | 7 | val deadline = 1.hour.fromNow 8 | do { 9 | println(s"Running application. Seconds left until showdown: ${deadline.timeLeft.toSeconds}") 10 | Thread.sleep(1000) 11 | } while (deadline.hasTimeLeft()) 12 | 13 | println("Application shutting down....") 14 | } -------------------------------------------------------------------------------- /examples/no-build/build.sbt: -------------------------------------------------------------------------------- 1 | enablePlugins(DockerComposePlugin) 2 | 3 | //Set this settings when none of images in the docker-compose.yml file need to be built 4 | composeNoBuild := true -------------------------------------------------------------------------------- /examples/no-build/docker/docker-compose.yml: -------------------------------------------------------------------------------- 1 | redis: 2 | image: redis 3 | ports: 4 | - "0:6379" -------------------------------------------------------------------------------- /examples/no-build/project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version=0.13.16 -------------------------------------------------------------------------------- /examples/no-build/project/plugins.sbt: -------------------------------------------------------------------------------- 1 | addSbtPlugin("com.tapad" % "sbt-docker-compose" % "1.0.34") -------------------------------------------------------------------------------- /project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version=0.13.16 2 | -------------------------------------------------------------------------------- /project/plugins.sbt: -------------------------------------------------------------------------------- 1 | addSbtPlugin("org.scalariform" % "sbt-scalariform" % "1.6.0") 2 | 3 | addSbtPlugin("com.jsuereth" % "sbt-pgp" % "1.0.0") 4 | 5 | addSbtPlugin("com.github.gseitz" % "sbt-release" % "1.0.6") 6 | 7 | addSbtPlugin("org.xerial.sbt" % "sbt-sonatype" % "1.1") 8 | -------------------------------------------------------------------------------- /screenshots/dockerComposeUp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tapad/sbt-docker-compose/adc033b68fc4ace4af2b7bb3a0d1e7a2207c8b78/screenshots/dockerComposeUp.png -------------------------------------------------------------------------------- /src/main/scala/com/tapad/docker/ComposeCustomTagHelpers.scala: -------------------------------------------------------------------------------- 1 | package com.tapad.docker 2 | 3 | /** 4 | * Set of helper functions for parsing Docker Compose "image:" values 5 | */ 6 | trait ComposeCustomTagHelpers { 7 | val latestVersion = "latest" 8 | 9 | /** 10 | * Parses the image name to get the "tag" value 11 | * @param imageName The full image name 12 | * @return Returns the "tag" value of on an image if it is defined. Otherwise, it will return "latest" as the tag. 13 | */ 14 | def getTagFromImage(imageName: String): String = imageName.lastIndexOf(':') match { 15 | case -1 => latestVersion 16 | case indexOfTag => imageName.substring(indexOfTag + 1) 17 | } 18 | 19 | /** 20 | * Replaces the currently defined tag with the newly specified one. If no tag exists or the "latest" tag is defined 21 | * then just return the original image. 22 | * @param imageName The full image name 23 | * @param newTag The new tag to put on the image 24 | * @return The updated image name with the previous tag replaced by newly specified tag 25 | */ 26 | def replaceDefinedVersionTag(imageName: String, newTag: String): String = (imageName.lastIndexOf(":"), imageName.endsWith(s":$latestVersion")) match { 27 | //Handle the case where the "latest" tag is used for the image. In this case disregard the sbt project version info 28 | case (-1, _) => imageName 29 | case (_, true) => imageName 30 | case (index, false) => s"${imageName.substring(0, index)}:$newTag" 31 | } 32 | 33 | /** 34 | * Remove tag from image name if it exists. 35 | * @param imageName The full image name. 36 | * @return The updated image name with the tag removed. 37 | */ 38 | def getImageNoTag(imageName: String): String = imageName.lastIndexOf(':') match { 39 | case -1 => imageName 40 | case index => imageName.substring(0, index) 41 | } 42 | 43 | /** 44 | * Returns the image name without a tag, organization info or Docker Registry information. With the image format being: 45 | * registry/org/image:tag 46 | * this function will return "image" or "org/image" if removeOrganization is false. 47 | * @param imageName The full image name 48 | * @param removeOrganization True to remove organization info, False to keep it. Default is True. 49 | * @return 50 | */ 51 | def getImageNameOnly(imageName: String, removeOrganization: Boolean = true): String = { 52 | val imageNoTag = getImageNoTag(imageName) 53 | 54 | //If there is no registry than return return image without a tag 55 | (imageNoTag.indexOf('/'), removeOrganization) match { 56 | case (-1, _) => imageNoTag 57 | case (_, true) => imageNoTag.substring(imageNoTag.lastIndexOf('/') + 1) 58 | case (indexOfRegistryEnd, false) => imageNoTag.substring(indexOfRegistryEnd + 1) 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/main/scala/com/tapad/docker/ComposeFile.scala: -------------------------------------------------------------------------------- 1 | package com.tapad.docker 2 | 3 | import java.io.{ File, FileWriter } 4 | import java.util 5 | import java.util.regex.{ Matcher, Pattern } 6 | 7 | import com.tapad.docker.DockerComposeKeys._ 8 | import org.yaml.snakeyaml.Yaml 9 | import sbt.Keys._ 10 | import sbt._ 11 | 12 | import scala.collection.JavaConverters._ 13 | import scala.collection.JavaConversions._ 14 | import scala.collection.{ Iterable, Seq } 15 | import scala.io.Source._ 16 | import scala.util.{ Failure, Success, Try } 17 | 18 | trait ComposeFile extends SettingsHelper with ComposeCustomTagHelpers with PrintFormatting { 19 | // Compose file Yaml keys 20 | val imageKey = "image" 21 | val environmentKey = "environment" 22 | val portsKey = "ports" 23 | val servicesKey = "services" 24 | val envFileKey = "env_file" 25 | val volumesKey = "volumes" 26 | val networksKey = "networks" 27 | 28 | //Set of values representing the source location of a Docker Compose image 29 | val cachedImageSource = "cache" 30 | val definedImageSource = "defined" 31 | val buildImageSource = "build" 32 | 33 | //Custom tags 34 | val useLocalBuildTag = "" 35 | val skipPullTag = "" 36 | 37 | val environmentDebugKey = "JAVA_TOOL_OPTIONS" 38 | 39 | //List of docker-compose fields that are currently unsupported by the plugin 40 | val unsupportedFields = List("build", "container_name", "extends") 41 | 42 | type yamlData = Map[String, java.util.LinkedHashMap[String, Any]] 43 | 44 | val useStaticPortsArg = "-useStaticPorts" 45 | val dynamicPortIdentifier = "0" 46 | 47 | /** 48 | * processCustomTags performs any pre-processing of Custom Tags in the Compose File before the Compose file is used 49 | * by Docker. This function will also determine any debug ports and rename any 'env_file' defined files to use their 50 | * fully qualified paths so that they can be accessed from the tmp location the docker-compose.yml is launched from 51 | * This function can be overridden in derived plug-ins to add additional custom tags to process 52 | * 53 | * @param state The sbt state 54 | * @param args Args passed to sbt command 55 | * @return The collection of ServiceInfo objects. The Compose Yaml passed in is also modified in-place so the calling 56 | * function will have the updates performed here 57 | */ 58 | def processCustomTags(implicit state: State, args: Seq[String], composeYaml: yamlData): Iterable[ServiceInfo] = { 59 | val useExistingImages = getSetting(composeNoBuild) 60 | val localService = getSetting(composeServiceName) 61 | val usedStaticPorts = scala.collection.mutable.Set[String]() 62 | 63 | getComposeFileServices(composeYaml).map { service => 64 | val (serviceName, serviceData) = service 65 | for (field <- unsupportedFields if serviceData.containsKey(field)) { 66 | throw ComposeFileFormatException(getUnsupportedFieldErrorMsg(field)) 67 | } 68 | val imageName = serviceData.get(imageKey).toString 69 | 70 | //Update Compose yaml with any images built as part of dockerComposeUp regardless of how it's defined in the 71 | //compose file 72 | val (updatedImageName, imageSource) = if (!useExistingImages && serviceName == localService) { 73 | //If the image does not contain a tag or has the tag "latest" it will not be replaced 74 | (replaceDefinedVersionTag(imageName, getComposeServiceVersion(state)), buildImageSource) 75 | } else if (imageName.toLowerCase.contains(useLocalBuildTag)) { 76 | (processImageTag(state, args, imageName), buildImageSource) 77 | } else if (imageName.toLowerCase.contains(skipPullTag) || containsArg(DockerComposePlugin.skipPullArg, args)) { 78 | (processImageTag(state, args, imageName), cachedImageSource) 79 | } else { 80 | (imageName, definedImageSource) 81 | } 82 | 83 | //Update env_file files to use the fully qualified path so that it can still be accessed from the tmp location 84 | if (serviceData.containsKey(envFileKey)) { 85 | val composeFileFullPath = new File(getSetting(composeFile)).getAbsolutePath 86 | val composeFileDir = composeFileFullPath.substring(0, composeFileFullPath.lastIndexOf(File.separator)) 87 | 88 | val entry = serviceData.get(envFileKey) 89 | entry match { 90 | case e: String => 91 | val updated = getFullyQualifiedPath(e, composeFileDir) 92 | serviceData.put(envFileKey, updated) 93 | case e: util.ArrayList[_] => 94 | val updated = e.asScala.map(file => getFullyQualifiedPath(file.asInstanceOf[String], composeFileDir)) 95 | serviceData.put(envFileKey, updated.asJava) 96 | } 97 | } 98 | 99 | //Update relative volumes to use the fully qualified path so they can still be accessed from the tmp location 100 | if (serviceData.containsKey(volumesKey)) { 101 | val composeFileFullPath = new File(getSetting(composeFile)).getAbsolutePath 102 | val composeFileDir = composeFileFullPath.substring(0, composeFileFullPath.lastIndexOf(File.separator)) 103 | 104 | val volumes = serviceData.get(volumesKey).asInstanceOf[util.List[String]].asScala 105 | val updated = volumes.map { volume => 106 | volume match { 107 | case relativeVolume if relativeVolume.startsWith(".") => 108 | val Array(relativeLocalPath, mountPath) = relativeVolume.split(":", 2) 109 | val fullyQualifiedLocalPath = getFullyQualifiedPath(relativeLocalPath, composeFileDir) 110 | s"$fullyQualifiedLocalPath:$mountPath" 111 | case nonRelativeVolume => 112 | nonRelativeVolume 113 | } 114 | } 115 | serviceData.put(volumesKey, updated.asJava) 116 | } 117 | 118 | serviceData.put(imageKey, updatedImageName) 119 | 120 | val useStatic = args.contains(useStaticPortsArg) 121 | val (updatedPortInfo, updatedPortList) = getPortInfo(serviceData, useStatic).zipped.map { (portInfo, portMapping) => 122 | if (useStatic) { 123 | if (usedStaticPorts.add(portMapping)) { 124 | (portInfo, portMapping) 125 | } else { 126 | val containerPort = portMapping.split(":").last 127 | printWarning(s"Could not define a static host port '$containerPort' for service '$serviceName' " + 128 | s"because port '$containerPort' was already in use. A dynamically assigned port will be used instead.", getSetting(suppressColorFormatting)(state)) 129 | (PortInfo(dynamicPortIdentifier, portInfo.containerPort, portInfo.isDebug), s"$dynamicPortIdentifier:$containerPort") 130 | } 131 | } else 132 | (portInfo, portMapping) 133 | }.unzip 134 | 135 | serviceData.put(portsKey, new util.ArrayList[String](updatedPortList)) 136 | 137 | ServiceInfo(serviceName, updatedImageName, imageSource, updatedPortInfo) 138 | } 139 | } 140 | 141 | /** 142 | * Gets the version to use for local image tagging in docker compose 143 | * @param state The sbt state 144 | * @return The version to use for local image tagging in docker compose 145 | */ 146 | def getComposeServiceVersion(implicit state: State): String = { 147 | val extracted = Project.extract(state) 148 | val (_, version) = extracted.runTask(composeServiceVersionTask, state) 149 | 150 | version 151 | } 152 | 153 | def getUnsupportedFieldErrorMsg(fieldName: String): String = { 154 | s"Docker Compose field '$fieldName:' is currently not supported by sbt-docker-compose. Please see the README for " + 155 | s"more information on the set of unsupported fields." 156 | } 157 | 158 | /** 159 | * Attempt to get the fully qualified path to a file. It will first attempt to find the file using the 160 | * path provided. If that fails it will attempt to find the file relative to the docker-compose yml location. Otherwise, 161 | * it will throw an exception with information about the file that could not be located. 162 | * 163 | * @param fileName The file name to find 164 | * @param composePath The path to the directory of the docker-compose yml file being used 165 | * @return The fully qualified path to the file 166 | */ 167 | def getFullyQualifiedPath(fileName: String, composePath: String): String = { 168 | if (new File(fileName).exists) { 169 | new File(fileName).getCanonicalFile.getAbsolutePath 170 | } else if (new File(s"$composePath/$fileName").exists) { 171 | new File(s"$composePath/$fileName").getCanonicalFile.getAbsolutePath 172 | } else { 173 | throw new IllegalStateException(s"Could not find file: '$fileName' either at the specified path or in the '$composePath' directory.") 174 | } 175 | } 176 | 177 | /** 178 | * If the Yaml is in the Docker 1.6 format which includes a new "services" key work with that sub-set of data. 179 | * Otherwise, return the original Yaml 180 | * 181 | * @param composeYaml Docker Compose yaml to process 182 | * @return The 'services' section of the Yaml file 183 | */ 184 | def getComposeFileServices(composeYaml: yamlData): yamlData = { 185 | composeYaml.get(servicesKey) match { 186 | case Some(services) => services.asInstanceOf[java.util.Map[String, java.util.LinkedHashMap[String, Any]]]. 187 | asScala.toMap 188 | case None => composeYaml 189 | } 190 | } 191 | 192 | def getComposeVersion(composeYaml: yamlData): Int = { 193 | composeYaml.get(servicesKey) match { 194 | case Some(services) => 2 195 | case None => 1 196 | } 197 | } 198 | 199 | /** 200 | * Get all non-external network names defined under the 'networks' key in the docker-compose file. 201 | * @param composeYaml Docker Compose yaml to process 202 | * @return The keys for the internal 'networks' section of the Yaml file 203 | */ 204 | def composeInternalNetworkNames(composeYaml: yamlData): Seq[String] = { 205 | composeYaml.get(networksKey) match { 206 | case Some(networks) => networks.filterNot { network => 207 | val (_, networkData) = network 208 | Option(networkData).exists(_.asInstanceOf[java.util.Map[String, Any]].containsKey("external")) 209 | }.keys.toSeq 210 | case None => Seq.empty 211 | } 212 | } 213 | 214 | /** 215 | * Get all named volumes defined under the 'volumes' key in the docker-compose file. 216 | * @param composeYaml Docker Compose yaml to process 217 | * @return The keys for the 'volumes' section of the Yaml file 218 | */ 219 | def composeNamedVolumes(composeYaml: yamlData): Seq[String] = { 220 | composeYaml.get(volumesKey) match { 221 | case Some(volumes) => volumes.keys.toSeq 222 | case None => Seq.empty 223 | } 224 | } 225 | 226 | /** 227 | * Function that reads plug-in defined "" fields from the Docker Compose file and performs some 228 | * transformation on the Docker File based on the tag. The file after transformations are applied is what is used by 229 | * Docker Compose to launch the instance. This function can be overridden in derived plug-ins to add additional tags 230 | * pre-processing features. 231 | * 232 | * @param state The sbt state 233 | * @param args Args passed to sbt command 234 | * @param imageName The image name and tag to be processed for example "testimage:1.0.0" This plugin just 235 | * removes the tags from the image name. 236 | * @return The updated image value after any processing indicated by the custom tags 237 | */ 238 | def processImageTag(implicit state: State, args: Seq[String], imageName: String): String = { 239 | imageName.replaceAll(s"(?i)$useLocalBuildTag", "").replaceAll(s"(?i)$skipPullTag", "") 240 | } 241 | 242 | /** 243 | * Parses the Port information from the Yaml content for a service. It will also report any ports that are exposed as 244 | * Debugging ports and expand any defined port ranges. Static ports will be used rather than the Docker dynamically 245 | * assigned ports when the '-useStaticPorts' argument is supplied. 246 | * 247 | * @param serviceKeys The Docker Compose Yaml representing a service 248 | * @param useStatic The flag used to indicate whether the '-useStaticPorts' argument is supplied 249 | * @return PortInfo collection and port mapping collection for all defined ports 250 | */ 251 | def getPortInfo(serviceKeys: java.util.LinkedHashMap[String, Any], useStatic: Boolean): (List[PortInfo], List[String]) = { 252 | if (serviceKeys.containsKey(portsKey)) { 253 | //Determine if there is a debug port set on the service 254 | val debugPort = if (serviceKeys.containsKey(environmentKey)) { 255 | val debugAddress = { 256 | serviceKeys.get(environmentKey) match { 257 | case key: util.LinkedHashMap[_, _] => 258 | val env = key.asInstanceOf[java.util.LinkedHashMap[String, String]].asScala 259 | val debugOptions = env.filter(_._1 == environmentDebugKey) 260 | debugOptions.flatMap(_._2.split(',')) 261 | case key: util.ArrayList[_] => 262 | val env = key.asInstanceOf[util.ArrayList[String]].asScala 263 | val debugOptions = env.filter(_.startsWith(environmentDebugKey)) 264 | debugOptions.flatMap(_.split(',')) 265 | } 266 | }.filter(_.contains("address")).mkString.split("=") 267 | 268 | if (debugAddress.size == 2) debugAddress(1) else "none" 269 | } 270 | 271 | //If any port ranges are defined expand them into individual ports 272 | val portRangeChar = "-" 273 | val (needsExpansion, noExpansion) = serviceKeys.get(portsKey).asInstanceOf[java.util.ArrayList[String]].asScala.partition(_.contains(portRangeChar)) 274 | val expandedPorts: Seq[String] = needsExpansion.flatMap { p => 275 | val portParts = p.replaceFirst("^0:", "").split(':') 276 | val portSplitL = portParts(0).split(portRangeChar) 277 | val (rangeStartL, rangeEndL) = (portSplitL(0), portSplitL(1)) 278 | val startL = rangeStartL.toInt 279 | val endL = rangeEndL.toInt 280 | val rangeL = endL - startL 281 | 282 | if (portParts.length == 1) { 283 | for (i <- 0 to rangeL) 284 | yield s"${startL + i}" 285 | } else { 286 | val portSplitR = portParts(1).split(portRangeChar) 287 | val (rangeStartR, rangeEndR) = (portSplitR(0), portSplitR(1)) 288 | val startR = rangeStartR.toInt 289 | val endR = rangeEndR.toInt 290 | val rangeR = endR - startR 291 | 292 | if (rangeL != rangeR) 293 | throw new IllegalStateException(s"Invalid port range mapping specified for $p") 294 | 295 | for (i <- 0 to rangeR) 296 | yield s"${startL + i}:${startR + i}" 297 | } 298 | } 299 | 300 | val ports = expandedPorts ++ noExpansion 301 | val list = { 302 | if (useStatic) 303 | getStaticPortMappings(ports) 304 | else 305 | new java.util.ArrayList[String](ports) 306 | } 307 | 308 | serviceKeys.put(portsKey, list) 309 | 310 | (serviceKeys.get(portsKey).asInstanceOf[java.util.ArrayList[String]].asScala.map(port => { 311 | val portArray = port.split(':') 312 | val (hostPort, containerPort) = if (portArray.length == 2) (portArray(0), portArray(1)) else (portArray(0), portArray(0)) 313 | val debugMatch = portArray.contains(debugPort) 314 | PortInfo(hostPort, containerPort, debugMatch) 315 | }).toList, list.toList) 316 | } else { 317 | (List.empty, List.empty) 318 | } 319 | } 320 | 321 | def getStaticPortMappings(ports: Seq[String]): java.util.ArrayList[String] = { 322 | val Pattern1 = (dynamicPortIdentifier + """:(\d+)(\D*)""").r 323 | val Pattern2 = """(\d+)(\D*)""".r 324 | 325 | val staticPorts = ports.map { 326 | case Pattern1(port, protocol) => s"$port:$port$protocol" 327 | case Pattern2(port, protocol) => s"$port:$port$protocol" 328 | case otherwise => otherwise 329 | } 330 | new java.util.ArrayList[String](staticPorts) 331 | } 332 | 333 | def readComposeFile(composePath: String, variables: Vector[(String, String)] = Vector.empty): yamlData = { 334 | val yamlString = fromFile(composePath).getLines().mkString("\n") 335 | val yamlUpdated = processVariableSubstitution(yamlString, variables) 336 | 337 | new Yaml().load(yamlUpdated).asInstanceOf[java.util.Map[String, java.util.LinkedHashMap[String, Any]]].asScala.toMap 338 | } 339 | 340 | /** 341 | * Substitute all docker-compose variables in the YAML file. This is traditionally done by docker-compose itself, 342 | * but is being performed by the plugin to support other functionality. 343 | * 344 | * @param yamlString Stringified docker-compose file. 345 | * @param variables Substitution variables. 346 | * @return An updated stringified docker-compile file. 347 | */ 348 | def processVariableSubstitution(yamlString: String, variables: Vector[(String, String)]): String = { 349 | //Substitute all defined environment variables allowing for the optional default value syntax ':-' 350 | val substitutedCompose = variables.foldLeft(yamlString) { 351 | case (y, (key, value)) => y.replaceAll("\\$\\{" + key + "(:-.*)?\\}", Matcher.quoteReplacement(value)) 352 | } 353 | 354 | //Find all remaining undefined environment variables which have a corresponding default value 355 | val defaultEnvRegex = "\\$\\{.*:-.*\\}".r 356 | val envToReplace = defaultEnvRegex.findAllIn(substitutedCompose).map { env => 357 | env.split(":-") match { 358 | case Array(_, default) => env -> default.replace("}", "") 359 | } 360 | } 361 | 362 | //Replace all undefined environment variables with the corresponding default value 363 | envToReplace.foldLeft(substitutedCompose) { 364 | case (y, (key, value)) => y.replaceAll(Pattern.quote(key), Matcher.quoteReplacement(value.replace("$", "$$"))) 365 | } 366 | } 367 | 368 | def deleteComposeFile(composePath: String): Boolean = { 369 | Try(new File(composePath).delete()) match { 370 | case Success(i) => true 371 | case Failure(t) => false 372 | } 373 | } 374 | 375 | /** 376 | * Saves the supplied Docker Compose Yaml data to a temporary file 377 | * 378 | * @param finalYaml Compose Yaml to save 379 | * @return The path to the temporary Compose File 380 | */ 381 | def saveComposeFile(finalYaml: yamlData): String = { 382 | val updatedComposePath = File.createTempFile("compose-updated", ".yml").getPath 383 | val writer = new FileWriter(updatedComposePath) 384 | try { 385 | new Yaml().dump(finalYaml.asJava, writer) 386 | } finally { 387 | writer.close() 388 | } 389 | 390 | updatedComposePath 391 | } 392 | } 393 | -------------------------------------------------------------------------------- /src/main/scala/com/tapad/docker/ComposeInstancePersistence.scala: -------------------------------------------------------------------------------- 1 | package com.tapad.docker 2 | 3 | import java.io._ 4 | 5 | import com.tapad.docker.DockerComposeKeys._ 6 | import sbt.{ State, _ } 7 | 8 | import scala.collection.Seq 9 | import scala.util.Try 10 | 11 | /** 12 | * Trait for defining how to save Docker Compose RunningInstanceInfo to both the sbt session state and to the disk for 13 | * longer term persistence across sbt sessions 14 | */ 15 | trait ComposeInstancePersistence extends SettingsHelper { 16 | val settingsFileName = "dockerComposeInstances.bin" 17 | val settingsFile = if (new File("/tmp").exists) { 18 | s"/tmp/$settingsFileName" 19 | } else { 20 | s"${System.getProperty("java.io.tmpdir")}$settingsFileName" 21 | } 22 | private var initialized = false 23 | 24 | /** 25 | * getPersistedState loads any saved dockerCompose instances from previous sbt sessions. It will only be loaded on the 26 | * initial call. 27 | * @param state The current application state which contains the set of instances running 28 | * @return The updated application state containing any running instances from exited sbt sessions 29 | */ 30 | def getPersistedState(implicit state: State): State = { 31 | if (!initialized) { 32 | initialized = true 33 | getAttribute(runningInstances) match { 34 | case Some(_) => state 35 | case None => 36 | Try { 37 | if (new File(settingsFile).exists) { 38 | val ois = new ObjectInputStream(new FileInputStream(settingsFile)) { 39 | override def resolveClass(desc: ObjectStreamClass): Class[_] = { 40 | try { 41 | Class.forName(desc.getName, false, getClass.getClassLoader) 42 | } catch { 43 | case ex: ClassNotFoundException => super.resolveClass(desc) 44 | } 45 | } 46 | } 47 | return setAttribute(runningInstances, ois.readObject().asInstanceOf[List[RunningInstanceInfo]]) 48 | } 49 | } 50 | state 51 | } 52 | } else { 53 | state 54 | } 55 | } 56 | 57 | /** 58 | * saveInstanceState will write out the current docker instance information to a temporary file so that it this 59 | * information can be used between sbt sessions. If the there are no instances then remove the file. 60 | * @param state The current application state which contains the set of instances running 61 | */ 62 | def saveInstanceState(implicit state: State): Unit = { 63 | Try(getAttribute(runningInstances) match { 64 | case Some(s) => 65 | val oos = new ObjectOutputStream(new FileOutputStream(settingsFile)) 66 | try { oos.writeObject(s) } finally { oos.close() } 67 | case None => 68 | new File(settingsFile).delete() 69 | }) 70 | } 71 | 72 | /** 73 | * Gets the sequence of running instance Id's for this sbt project 74 | * @param state The current application state which contains the set of instances running 75 | * @return Sequence of running instance Id's for this sbt project 76 | */ 77 | def getServiceRunningInstanceIds(implicit state: State): Seq[String] = getAttribute(runningInstances) match { 78 | //By default if no arguments are passed return all instances from current sbt project 79 | case Some(launchedInstances) => 80 | //Get the instance names that map to the current sbt projects defined service 81 | launchedInstances.filter(_.composeServiceName.equalsIgnoreCase(getSetting(composeServiceName))).map(_.instanceName) 82 | case None => 83 | Seq.empty 84 | } 85 | 86 | /** 87 | * Gets the sequence of running instance Id's for all instances 88 | * @param state The current application state which contains the set of instances running 89 | * @return Sequence of running instance Id's for this sbt project 90 | */ 91 | def getAllRunningInstanceIds(implicit state: State): Seq[String] = getAttribute(runningInstances) match { 92 | case Some(launchedInstances) => 93 | //Get the instance names that map to the current sbt projects defined service 94 | launchedInstances.map(_.instanceName) 95 | case None => 96 | Seq.empty 97 | } 98 | 99 | /** 100 | * Gets a matching Running Instance if it exists 101 | * @param state The current application state which contains the set of instances running 102 | * @param args Arguments given to an sbt command 103 | * @return The first instance that matches the input args 104 | */ 105 | def getMatchingRunningInstance(implicit state: State, args: Seq[String]): Option[RunningInstanceInfo] = getAttribute(runningInstances) match { 106 | case Some(launchedInstances) => 107 | val matchingInstance = for { 108 | arg <- args 109 | instance <- launchedInstances 110 | if arg == instance.instanceName 111 | } yield instance 112 | 113 | matchingInstance.headOption 114 | case None => None 115 | } 116 | 117 | /** 118 | * Updates the sbt session information into includes the new RunningInstanceInfo object 119 | * @param state The current application state which contains the set of instances running 120 | * @param instance The instance information to save 121 | * @return The updated State which includes the new RunningInstnaceInfo object 122 | */ 123 | def saveInstanceToSbtSession(implicit state: State, instance: RunningInstanceInfo): State = getAttribute(runningInstances) match { 124 | //Save a list of generated Service Names and port mappings so that it can be used for the dockerComposeStop command 125 | case Some(names) => setAttribute(runningInstances, names ::: List(instance)) 126 | case None => setAttribute(runningInstances, List(instance)) 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /src/main/scala/com/tapad/docker/ComposeTestRunner.scala: -------------------------------------------------------------------------------- 1 | package com.tapad.docker 2 | 3 | import com.tapad.docker.DockerComposeKeys._ 4 | import sbt.{ Project, _ } 5 | 6 | import scala.collection.Seq 7 | import scala.sys.process.Process 8 | 9 | trait ComposeTestRunner extends SettingsHelper with PrintFormatting { 10 | val testDebugPortArg = "-debug" 11 | val testTagOverride = "-tags" 12 | 13 | /** 14 | * Compiles and binPackages latest test code 15 | * 16 | * @param state The sbt state 17 | */ 18 | def binPackageTests(implicit state: State): Unit = { 19 | val extracted = Project.extract(state) 20 | state.globalLogging.full.info(s"Compiling and Packaging test cases using ${testCasesPackageTask.key.label} ...") 21 | try { 22 | extracted.runTask(testCasesPackageTask, state) 23 | } catch { 24 | case e: Exception => 25 | throw TestCodeCompilationException(e.getMessage) 26 | } 27 | } 28 | 29 | /** 30 | * Gets a classpath representing all managed and unmanaged dependencies in the Test and Compile Scope for this sbt project. 31 | * 32 | * @param state The sbt state 33 | * @return The full set of classpath entries used by Test and Compile 34 | */ 35 | def getTestDependenciesClassPath(implicit state: State): String = { 36 | val extracted = Project.extract(state) 37 | val (_, testClassPath) = extracted.runTask(testDependenciesClasspath, state) 38 | 39 | testClassPath 40 | } 41 | 42 | /** 43 | * Gets extra key value pairs to pass to ScalaTest in the configMap. 44 | * 45 | * @param state The sbt state 46 | * @return A Map[String,String] of variables to pass into the ScalaTest Runner ConfigMap 47 | */ 48 | def runTestExecutionExtraConfigTask(state: State): Map[String, String] = { 49 | val extracted = Project.extract(state) 50 | val (_, value) = extracted.runTask(testExecutionExtraConfigTask, state) 51 | value 52 | } 53 | 54 | /** 55 | * Build up a set of parameters to pass to ScalaTest as a configMap. 56 | * Generates the list of ScalaTest Tests to execute. 57 | * Compiles and binPackages the latest Test code. 58 | * Starts a test pass using the ScalaTest Runner 59 | * Note: For this to work properly the version of the Scala executable on your path needs to of the same version 60 | * that the ScalaTest Jar was compiled with. For example, if you are using ScalaTest 2.10.X Scala must be of 61 | * version 2.10.X. 62 | * 63 | * @param state The sbt state 64 | * @param args The command line arguments 65 | * @param instance The running Docker Compose instance to test against 66 | */ 67 | def runTestPass(implicit state: State, args: Seq[String], instance: Option[RunningInstanceInfo]): State = { 68 | runInContainer("ScalaTest", ExecuteInput.ScalaTest) 69 | } 70 | 71 | /** 72 | * Build up a set of parameters to pass as System Properties that can be accessed from Specs2 73 | * Compiles and binPackages the latest Test code. 74 | * Starts a test pass using the Specs2 Files Runner 75 | * 76 | * @param state The sbt state 77 | * @param args The command line arguments 78 | * @param instance The running Docker Compose instance to test against 79 | */ 80 | def runTestPassSpecs2(implicit state: State, args: Seq[String], instance: Option[RunningInstanceInfo]): State = { 81 | runInContainer("Specs2", ExecuteInput.Specs2) 82 | } 83 | 84 | /** 85 | * Build up a set of parameters to pass as System Properties that can be accessed from Cucumber (Cukes) 86 | * Compiles and binPackages the latest Test code. 87 | * Starts a test pass using the Cucumber Runner 88 | * 89 | * @param state The sbt state 90 | * @param args The command line arguments 91 | * @param instance The running Docker Compose instance to test against 92 | */ 93 | def runTestPassCucumber(implicit state: State, args: Seq[String], instance: Option[RunningInstanceInfo]): State = { 94 | runInContainer("Cucumber", ExecuteInput.Cucumber) 95 | } 96 | 97 | protected def runInContainer(testDesc: String, run: ExecuteInput.Invoke)(implicit state: State, args: Seq[String], instance: Option[RunningInstanceInfo]): State = { 98 | 99 | val extraTestParams = runTestExecutionExtraConfigTask(state).map { case (k, v) => s"-D$k=$v" } 100 | 101 | binPackageTests 102 | 103 | // Looks for the <-debug:port> argument and will suspend test case execution until a debugger is attached 104 | val debugSettings: String = getArgValue(testDebugPortArg, args) match { 105 | case Some(port) => s"-agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=$port" 106 | case None => "" 107 | } 108 | 109 | val suppressColor = getSetting(suppressColorFormatting) 110 | val testParamsList: Seq[String] = { 111 | //Build the list of Docker Compose connection endpoints to pass as System Properties 112 | //format: <-Dservice:containerPort=host:hostPort> 113 | val testParams = instance match { 114 | case Some(inst) => inst.servicesInfo.flatMap(service => 115 | service.ports.map(port => 116 | s"-D${service.serviceName}:${port.containerPort}=${service.containerHost}:${port.hostPort}") 117 | :+ s"-D${service.serviceName}:containerId=${service.containerId}").mkString(" ") 118 | case None => "" 119 | } 120 | testParams.split(" ").toSeq ++ extraTestParams 121 | } 122 | 123 | val testDependencies: String = getTestDependenciesClassPath 124 | val testInput = new ExecuteInput(this, testDependencies, testParamsList, debugSettings) 125 | 126 | if (run.isDefinedAt(testInput)) { 127 | val testRunnerCommand = run(testInput) 128 | 129 | if (Process(testRunnerCommand).! == 0) state 130 | else state.fail 131 | } else { 132 | printBold(s"Cannot find a $testDesc Jar dependency. Please make sure it is added to your sbt projects libraryDependencies.", suppressColor) 133 | state 134 | } 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /src/main/scala/com/tapad/docker/DockerCommands.scala: -------------------------------------------------------------------------------- 1 | package com.tapad.docker 2 | 3 | import sbt._ 4 | import scala.sys.process.Process 5 | import com.tapad.docker.DockerComposeKeys._ 6 | 7 | trait DockerCommands { 8 | def dockerComposeUp(instanceName: String, composePath: String): Int = { 9 | Process(s"docker-compose -p $instanceName -f $composePath up -d").! 10 | } 11 | 12 | def dockerComposeStopInstance(instanceName: String, composePath: String): Unit = { 13 | Process(s"docker-compose -p $instanceName -f $composePath stop").! 14 | } 15 | 16 | def dockerComposeRemoveContainers(instanceName: String, composePath: String): Unit = { 17 | Process(s"docker-compose -p $instanceName -f $composePath rm -v -f").! 18 | } 19 | 20 | def dockerNetworkExists(instanceName: String, networkName: String): Boolean = { 21 | //Docker replaces '/' with '_' in the identifier string so search for replaced version 22 | //Use '-q' instead of '--format' as format was only introduced in Docker v1.13.0-rc1 23 | Process(s"docker network ls -q --filter=name=${instanceName.replace('/', '_')}_$networkName").!!.trim().nonEmpty 24 | } 25 | 26 | def dockerVolumeExists(instanceName: String, volumeName: String): Boolean = { 27 | //Docker replaces '/' with '_' in the identifier string so search for replaced version 28 | Process(s"docker volume ls -q --filter=name=${instanceName.replace('/', '_')}_$volumeName").!!.trim().nonEmpty 29 | } 30 | 31 | def getDockerComposeVersion: Version = { 32 | val version = Process("docker-compose version --short").!! 33 | Version(version) 34 | } 35 | 36 | def dockerPull(imageName: String): Unit = { 37 | Process(s"docker pull $imageName").! 38 | } 39 | 40 | def dockerMachineIp(machineName: String): String = { 41 | Process(s"docker-machine ip $machineName").!!.trim 42 | } 43 | 44 | def getDockerContainerId(instanceName: String, serviceName: String): String = { 45 | //Docker replaces '/' with '_' in the identifier string so search for replaced version 46 | Process(s"""docker ps --all --filter=name=${instanceName.replace('/', '_')}_${serviceName}_ --format=\"{{.ID}}\"""").!!.trim().replaceAll("\"", "") 47 | } 48 | 49 | def getDockerContainerInfo(containerId: String): String = { 50 | Process(s"docker inspect --type=container $containerId").!! 51 | } 52 | 53 | def dockerRemoveImage(imageName: String): Unit = { 54 | Process(s"docker rmi $imageName").!! 55 | } 56 | 57 | def dockerRemoveNetwork(instanceName: String, networkName: String): Unit = { 58 | Process(s"docker network rm ${instanceName}_$networkName").! 59 | } 60 | 61 | def dockerRemoveVolume(instanceName: String, volumeName: String): Unit = { 62 | Process(s"docker volume rm ${instanceName}_$volumeName").! 63 | } 64 | 65 | def dockerTagImage(currentImageName: String, newImageName: String): Unit = { 66 | Process(s"docker tag $currentImageName $newImageName").!! 67 | } 68 | 69 | def dockerPushImage(imageName: String): Unit = { 70 | Process(s"docker push $imageName").! 71 | } 72 | 73 | def dockerRun(command: String): Unit = { 74 | Process(s"docker run $command").! 75 | } 76 | 77 | def getDockerPortMappings(containerId: String): String = { 78 | Process(s"docker port $containerId").!! 79 | } 80 | 81 | def isDockerForMacEnvironment: Boolean = { 82 | val info = Process("docker info").!! 83 | info.contains("Operating System: Docker for Mac") || 84 | info.contains("Operating System: Docker Desktop") || 85 | (info.contains("Operating System: Alpine Linux") && info.matches("(?s).*Kernel Version:.*-moby.*")) 86 | } 87 | 88 | /** 89 | * If running on Boot2Docker environment on OSX use the machine IP else use the container host 90 | * @return True if Boot2Docker, Otherwise False 91 | */ 92 | def isBoot2DockerEnvironment: Boolean = sys.env.get("DOCKER_MACHINE_NAME").isDefined 93 | 94 | /** 95 | * Builds a docker image for an sbt project using the user defined task. 96 | * @param state The sbt state 97 | */ 98 | def buildDockerImageTask(state: State): Unit = { 99 | val extracted = Project.extract(state) 100 | extracted.runTask(dockerImageCreationTask, state) 101 | } 102 | 103 | /** 104 | * Gets variables to use for docker-compose file substitution 105 | * @param state The sbt state 106 | */ 107 | def runVariablesForSubstitutionTask(state: State): Vector[(String, String)] = { 108 | val extracted = Project.extract(state) 109 | val (_, value) = extracted.runTask(variablesForSubstitutionTask, state) 110 | value.toVector 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /src/main/scala/com/tapad/docker/DockerComposeKeys.scala: -------------------------------------------------------------------------------- 1 | package com.tapad.docker 2 | 3 | import sbt._ 4 | import java.io._ 5 | 6 | object DockerComposeKeys extends DockerComposeKeysLocal 7 | 8 | trait DockerComposeKeysLocal { 9 | val composeContainerPauseBeforeTestSeconds = settingKey[Int]("Delay between containers start and test execution, seconds. Default is 0 seconds - no delay") 10 | val composeFile = settingKey[String]("Specify the full path to the Compose File to use to create your test instance. It defaults to docker-compose.yml in your resources folder.") 11 | val composeServiceName = settingKey[String]("The name of the service in the Docker Compose file being tested. This setting prevents the service image from being pull down from the Docker Registry. This defaults to the Project name.") 12 | val composeServiceVersionTask = taskKey[String]("The version to tag locally built images with in the docker-compose file. This defaults to the 'version' SettingKey.") 13 | val composeNoBuild = settingKey[Boolean]("True if a Docker Compose file is to be started without building any images and only using ones that already exist in the Docker Registry. This defaults to False.") 14 | val composeRemoveContainersOnShutdown = settingKey[Boolean]("True if a Docker Compose should remove containers when shutting down the compose instance. This defaults to True.") 15 | val composeRemoveNetworkOnShutdown = settingKey[Boolean]("True if a Docker Compose should remove the network it created when shutting down the compose instance. This defaults to True.") 16 | val composeRemoveTempFileOnShutdown = settingKey[Boolean]("True if a Docker Compose should remove the post Custom Tag processed Compose File on shutdown. This defaults to True.") 17 | val composeContainerStartTimeoutSeconds = settingKey[Int]("The amount of time in seconds to wait for the containers in a Docker Compose instance to start. Defaults to 500 seconds.") 18 | val dockerMachineName = settingKey[String]("If running on OSX the name of the Docker Machine Virtual machine being used. If not overridden it is set to 'default'") 19 | val dockerImageCreationTask = taskKey[Any]("The sbt task used to create a Docker image. For sbt-docker this should be set to 'docker.value' for the sbt-native-packager this should be set to '(publishLocal in Docker).value'.") 20 | val suppressColorFormatting = settingKey[Boolean]("True to suppress all color formatting in the output from the plugin. This defaults to the value of the 'sbt.log.noformat' property.") 21 | val testTagsToExecute = settingKey[String]("Set of ScalaTest Tags to execute when dockerComposeTest is run. Separate multiple tags by a comma. It defaults to executing all tests.") 22 | val testExecutionExtraConfigTask = taskKey[Map[String, String]]("Additional ScalaTest Runner configuration to pass into the ConfigMap.") 23 | val testExecutionArgs = settingKey[String]("Additional ScalaTest Runner argument options to pass into the test runner. For example, this can be used for the generation of test reports.") 24 | val testCasesJar = settingKey[String]("The path to the Jar file containing the tests to execute. This defaults to the Jar file with the tests from the current sbt project.") 25 | val testCasesPackageTask = taskKey[File]("The sbt TaskKey to package the test cases used when running 'dockerComposeTest'. This defaults to the 'packageBin' task in the 'Test' Scope.") 26 | val testDependenciesClasspath = taskKey[String]("The path to all managed and unmanaged Test and Compile dependencies. This path needs to include the ScalaTest Jar for the tests to execute. This defaults to all managedClasspath and unmanagedClasspath in the Test and fullClasspath in the Compile Scope.") 27 | val testPassUseSpecs2 = settingKey[Boolean]("True if Specs2 is to be used to execute the test pass. This defaults to False and ScalaTest is used.") 28 | val testPassUseCucumber = settingKey[Boolean]("True if Cucumber is to be used to execute the test pass. This defaults to False and ScalaTest is used.") 29 | val runningInstances = AttributeKey[List[RunningInstanceInfo]]("runningInstances", "For Internal Use: Contains information on the set of running Docker Compose instances.") 30 | val variablesForSubstitution = settingKey[Map[String, String]]("A Map[String,String] of variables to substitute in your docker-compose file. These are substituted by the plugin and not using environment variables.") 31 | val variablesForSubstitutionTask = taskKey[Map[String, String]]("An sbt task that returns a Map[String,String] of variables to substitute in your docker-compose file. These are substituted by the plugin and not using environment variables.") 32 | } 33 | -------------------------------------------------------------------------------- /src/main/scala/com/tapad/docker/DockerComposeSettings.scala: -------------------------------------------------------------------------------- 1 | package com.tapad.docker 2 | 3 | import java.io.File 4 | 5 | import sbt._ 6 | import sbt.Keys._ 7 | import com.tapad.docker.DockerComposeKeys._ 8 | import com.tapad.docker.DockerComposePlugin._ 9 | 10 | object DockerComposeSettings extends DockerComposeSettingsLocal 11 | 12 | trait DockerComposeSettingsLocal extends PrintFormatting { 13 | lazy val baseDockerComposeSettings = Seq( 14 | // Attempt to read the compose file from the resources folder followed by a docker folder off the base directory of the project followed by the root directory 15 | composeFile := { 16 | val dockerFileName = "docker-compose.yml" 17 | val dockerFileInResources = (resourceDirectory in Compile).value / dockerFileName toString () 18 | val dockerFileInDir = s"${baseDirectory.value.absolutePath}/docker/$dockerFileName" 19 | if (new File(dockerFileInResources).exists) { 20 | dockerFileInResources 21 | } else if (new File(dockerFileInDir).exists) { 22 | dockerFileInDir 23 | } else { 24 | s"${baseDirectory.value.absolutePath}/$dockerFileName" 25 | } 26 | }, 27 | // By default set the Compose service name to be that of the sbt Project Name 28 | composeServiceName := name.value.toLowerCase, 29 | composeServiceVersionTask := version.value, 30 | composeNoBuild := false, 31 | composeRemoveContainersOnShutdown := true, 32 | composeRemoveNetworkOnShutdown := true, 33 | composeRemoveTempFileOnShutdown := true, 34 | composeContainerStartTimeoutSeconds := 500, 35 | composeContainerPauseBeforeTestSeconds := 0, 36 | dockerMachineName := "default", 37 | dockerImageCreationTask := printError("***Warning: The 'dockerImageCreationTask' has not been defined. " + 38 | "Please configure this setting to have Docker images built.***", suppressColorFormatting.value), 39 | testTagsToExecute := "", 40 | testExecutionExtraConfigTask := Map.empty[String, String], 41 | testExecutionArgs := "", 42 | testDependenciesClasspath := { 43 | val fullClasspathCompile = (fullClasspath in Compile).value 44 | val classpathTestManaged = (managedClasspath in Test).value 45 | val classpathTestUnmanaged = (unmanagedClasspath in Test).value 46 | val testResources = (resources in Test).value 47 | val testPath = Seq((classDirectory in Test).value) 48 | (testResources ++ testPath ++ fullClasspathCompile.files ++ classpathTestManaged.files ++ classpathTestUnmanaged.files).map(_.getAbsoluteFile).mkString(File.pathSeparator) 49 | }, 50 | testCasesJar := artifactPath.in(Test, packageBin).value.getAbsolutePath, 51 | testCasesPackageTask := (sbt.Keys.packageBin in Test).value, 52 | testPassUseSpecs2 := false, 53 | testPassUseCucumber := false, 54 | suppressColorFormatting := System.getProperty("sbt.log.noformat", "false") == "true", 55 | variablesForSubstitution := Map[String, String](), 56 | variablesForSubstitutionTask := Map[String, String](), 57 | commands ++= Seq(dockerComposeUpCommand, dockerComposeStopCommand, dockerComposeRestartCommand, dockerComposeInstancesCommand, dockerComposeTest) 58 | ) 59 | } -------------------------------------------------------------------------------- /src/main/scala/com/tapad/docker/ExecuteInput.scala: -------------------------------------------------------------------------------- 1 | package com.tapad.docker 2 | 3 | import com.tapad.docker.DockerComposeKeys.{ suppressColorFormatting, testCasesJar, testTagsToExecute } 4 | import sbt.State 5 | 6 | import scala.collection.Seq 7 | 8 | /** 9 | * Represents the settings/input given to produce a test command-line. 10 | */ 11 | case class ExecuteInput( 12 | runner: ComposeTestRunner, 13 | testDependencyClasspath: String, 14 | testParamsList: Seq[String], 15 | debugSettings: String 16 | )(implicit 17 | val state: State, 18 | val args: Seq[String], 19 | val instance: Option[RunningInstanceInfo]) { 20 | def matches(regex: String) = testDependencyClasspath.matches(regex) 21 | 22 | def testArgs = runner.getSetting(DockerComposeKeys.testExecutionArgs).split(" ").toSeq 23 | 24 | def suppressColor = runner.getSetting(suppressColorFormatting) 25 | 26 | def formattedClasspath = { 27 | testDependencyClasspath.split("[;:,]", -1).mkString("\n") 28 | } 29 | 30 | override def toString = { 31 | s"""Docker Compose Test Input: 32 | |testParamsList: $testParamsList 33 | |debugSettings: $debugSettings 34 | |testClasspath: $formattedClasspath 35 | """.stripMargin 36 | } 37 | } 38 | 39 | object ExecuteInput { 40 | type TestRunnerCommand = Seq[String] 41 | 42 | /** 43 | * Given an ExecuteInput, produce an option command-line used to execute some tests 44 | */ 45 | type Invoke = PartialFunction[ExecuteInput, TestRunnerCommand] 46 | 47 | /** 48 | * A function which will execute ScalaTests given an [[ExecuteInput]] 49 | */ 50 | val ScalaTest: PartialFunction[ExecuteInput, TestRunnerCommand] = { 51 | case input: ExecuteInput if input.matches(".*org.scalatest.*") => 52 | 53 | val outputArg = "-o" 54 | val testTags = (input.runner.getArgValue(input.runner.testTagOverride, input.args) match { 55 | case Some(tag) => tag 56 | case None => input.runner.getSetting(testTagsToExecute)(input.state) 57 | }).split(',').filter(_.nonEmpty).map(tag => s"-n $tag").mkString(" ") 58 | 59 | val noColorOption = if (input.suppressColor) "W" else "" 60 | 61 | //If testArgs contains '-o' values then parse them out to combine with the existing '-o' setting 62 | val (testArgsOutput, testArgs) = input.testArgs.partition(_.startsWith(outputArg)) 63 | val outputFormattingArgs = testArgsOutput.map(_.replace(outputArg, "")).headOption.getOrElse("") 64 | 65 | val jarName = input.runner.getSetting(testCasesJar)(input.state) 66 | 67 | val testRunnerCommand: Seq[String] = (Seq("java", input.debugSettings) ++ 68 | input.testParamsList ++ 69 | Seq("-cp", input.testDependencyClasspath, "org.scalatest.tools.Runner", s"$outputArg$noColorOption$outputFormattingArgs") ++ 70 | testArgs ++ 71 | Seq("-R", s"${jarName.replace(" ", "\\ ")}") ++ 72 | testTags.split(" ").toSeq ++ 73 | input.testParamsList).filter(_.nonEmpty) 74 | 75 | testRunnerCommand 76 | } 77 | 78 | /** 79 | * A function which will execute Specs2 tests given an [[ExecuteInput]] 80 | */ 81 | val Specs2: PartialFunction[ExecuteInput, TestRunnerCommand] = { 82 | case input: ExecuteInput if input.matches(".*org.specs2.*") => 83 | 84 | val noColorOption = if (input.suppressColor) "-Dspecs2.color=false" else "" 85 | 86 | val testParamsList = input.testParamsList 87 | val testRunnerCommand = (Seq("java", input.debugSettings, noColorOption) ++ 88 | input.testParamsList ++ 89 | Seq("-cp", input.testDependencyClasspath, "org.specs2.runner.files") ++ 90 | input.testArgs ++ 91 | testParamsList).filter(_.nonEmpty) 92 | 93 | testRunnerCommand 94 | } 95 | 96 | /** 97 | * A function which will execute Cucumber tests given an [[ExecuteInput]] 98 | * 99 | * @see https://cucumber.io/docs/reference/jvm#java 100 | */ 101 | val Cucumber: PartialFunction[ExecuteInput, TestRunnerCommand] = { 102 | case input: ExecuteInput if input.matches(".*cucumber.*") => 103 | 104 | val cucumberArgs = { 105 | val gluePath = "classpath:" 106 | val featurePath = "classpath:" 107 | 108 | val noColorOption = if (input.suppressColor) "-m" else "" 109 | Seq("--glue", gluePath, noColorOption, featurePath) ++ input.testArgs 110 | } 111 | val testRunnerCommand = (Seq("java", input.debugSettings) ++ 112 | input.testParamsList ++ 113 | Seq("-cp", input.testDependencyClasspath, "cucumber.api.cli.Main") ++ 114 | cucumberArgs).filter(_.nonEmpty) 115 | 116 | testRunnerCommand 117 | } 118 | } -------------------------------------------------------------------------------- /src/main/scala/com/tapad/docker/OutputTable.scala: -------------------------------------------------------------------------------- 1 | package com.tapad.docker 2 | 3 | case class OutputTable(table: List[List[String]]) { 4 | 5 | val defBorderSeparator = "+" 6 | val defBorderFiller = "-" 7 | val defRowSeparator = "|" 8 | 9 | // List that contains the max number of characters per column. 10 | val columnWidthList = table.transpose.map(_.map(_.length).reduceOption(math.max)) 11 | 12 | def mkBorder(borderSeparator: String = defBorderSeparator, borderFiller: String = defBorderFiller): String = { 13 | columnWidthList 14 | .map(borderFiller * _.getOrElse(0)) 15 | .mkString(s"$borderSeparator$borderFiller", s"$borderFiller$borderSeparator$borderFiller", s"$borderFiller$borderSeparator") 16 | } 17 | 18 | def mkRow(row: List[String], separator: String = defRowSeparator): String = { 19 | row.zip(columnWidthList) 20 | .map { 21 | case (cellString, Some(width)) => cellString + " " * (width - cellString.length) 22 | case _ => "" 23 | } 24 | .mkString(s"$separator ", s" $separator ", s" $separator") 25 | } 26 | 27 | override def toString: String = { 28 | table match { 29 | case Nil => "" 30 | case header :: rows => 31 | List( 32 | mkBorder(), 33 | mkRow(header), 34 | mkBorder(borderFiller = "="), 35 | rows.map(mkRow(_)).mkString("\n"), 36 | mkBorder() 37 | ).mkString("\n") 38 | } 39 | } 40 | } -------------------------------------------------------------------------------- /src/main/scala/com/tapad/docker/OutputTableRow.scala: -------------------------------------------------------------------------------- 1 | package com.tapad.docker 2 | 3 | case class OutputTableRow( 4 | serviceName: String, 5 | hostWithPort: String, 6 | versionTag: String, 7 | imageSource: String, 8 | containerPort: String, 9 | containerId: String, 10 | isDebug: Boolean 11 | ) extends Ordered[OutputTableRow] { 12 | 13 | def toStringList: List[String] = 14 | List( 15 | serviceName, 16 | hostWithPort, 17 | versionTag, 18 | imageSource, 19 | containerPort, 20 | containerId, 21 | if (isDebug) "DEBUG" else "" 22 | ) 23 | 24 | def compare(that: OutputTableRow): Int = { 25 | // Sort Order 26 | // - Service Name - Alphabetically 27 | // - isDebug - Debug Ports always go at the end 28 | // - Container Port - Sorted by port number from lowest to highest 29 | if (this.serviceName == that.serviceName) { 30 | if (this.isDebug == that.isDebug) { 31 | val leftContainerPort = this.containerPort.split("/").head.toInt 32 | val rightContainerPort = that.containerPort.split("/").head.toInt 33 | leftContainerPort compare rightContainerPort 34 | } else { 35 | this.isDebug compare that.isDebug 36 | } 37 | } else { 38 | this.serviceName compare that.serviceName 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/main/scala/com/tapad/docker/PrintFormatting.scala: -------------------------------------------------------------------------------- 1 | package com.tapad.docker 2 | 3 | import sbt._ 4 | import scala.Console._ 5 | import scala.collection.Iterable 6 | import com.tapad.docker.DockerComposeKeys._ 7 | 8 | trait PrintFormatting extends SettingsHelper { 9 | // Allows for standard print statements to be inspected for test purposes 10 | def print(s: String) = println(s) 11 | 12 | def printBold(input: String, suppressColor: Boolean): Unit = { 13 | if (suppressColor) { 14 | print(input) 15 | } else { 16 | print(BOLD + input + RESET) 17 | } 18 | } 19 | 20 | def printWarning(input: String, suppressColor: Boolean): Unit = { 21 | if (suppressColor) { 22 | print(input) 23 | } else { 24 | print(YELLOW + input + RESET) 25 | } 26 | } 27 | 28 | def printSuccess(input: String, suppressColor: Boolean): Unit = { 29 | if (suppressColor) { 30 | print(input) 31 | } else { 32 | print(GREEN + input + RESET) 33 | } 34 | } 35 | 36 | def printError(input: String, suppressColor: Boolean): Unit = { 37 | if (suppressColor) { 38 | print(input) 39 | } else { 40 | print(RED + input + RESET) 41 | } 42 | } 43 | 44 | def printTable(implicit state: State, rows: Iterable[OutputTableRow]): Unit = { 45 | val tableHeader = List( 46 | "Service", 47 | "Host:Port", 48 | "Tag Version", 49 | "Image Source", 50 | "Container Port", 51 | "Container Id", 52 | "IsDebug" 53 | ) 54 | val sortedTableEntries = rows 55 | .toList 56 | .sorted 57 | val outputTable = OutputTable(tableHeader :: sortedTableEntries.map(_.toStringList)) 58 | 59 | printSuccess(outputTable.toString, getSetting(suppressColorFormatting)) 60 | } 61 | 62 | def printMappedPortInformation(implicit state: State, instance: RunningInstanceInfo, composeVersion: Version): Unit = { 63 | val suppressColor = getSetting(suppressColorFormatting) 64 | printBold(s"\nThe following endpoints are available for your local instance: ${instance.instanceName}", suppressColor) 65 | printTable(state, getTableOutputList(instance.servicesInfo)) 66 | 67 | print("Instance commands:") 68 | 69 | print(s"1) To stop instance from sbt run:") 70 | printSuccess(s" dockerComposeStop ${instance.instanceName}", suppressColor) 71 | 72 | print(s"2) To open a command shell from bash run:") 73 | printSuccess(s" docker exec -it bash", suppressColor) 74 | 75 | print(s"3) To view log files from bash run:") 76 | 77 | val tailFlag = if (composeVersion.major > 1 || (composeVersion.major == 1 && composeVersion.minor >= 7)) "-f" else "" 78 | printSuccess(s" docker-compose -p ${instance.instanceName} -f ${instance.composeFilePath} logs $tailFlag", suppressColor) 79 | 80 | print(s"4) To execute test cases against instance from sbt run:") 81 | printSuccess(s" dockerComposeTest ${instance.instanceName}", suppressColor) 82 | } 83 | 84 | def getTableOutputList(servicesInfo: Iterable[ServiceInfo]): Iterable[OutputTableRow] = { 85 | servicesInfo.flatMap { service => 86 | if (service.ports.isEmpty) { 87 | List( 88 | OutputTableRow( 89 | service.serviceName, 90 | service.containerHost + ":" + "", 91 | service.versionTag, service.imageSource, 92 | "", 93 | service.containerId, 94 | false 95 | ) 96 | ) 97 | } else { 98 | service.ports.map { port => 99 | OutputTableRow( 100 | service.serviceName, 101 | service.containerHost + ":" + port.hostPort, 102 | service.versionTag, 103 | service.imageSource, 104 | port.containerPort, 105 | service.containerId, 106 | port.isDebug 107 | ) 108 | } 109 | } 110 | } 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /src/main/scala/com/tapad/docker/SettingsHelper.scala: -------------------------------------------------------------------------------- 1 | package com.tapad.docker 2 | 3 | import sbt._ 4 | 5 | import scala.collection.Seq 6 | 7 | /** 8 | * Access all SBT project settings and attributes through this trait so that the values can be mocked under test 9 | */ 10 | trait SettingsHelper { 11 | def getSetting[T](setting: SettingKey[T])(implicit state: State): T = { 12 | val extracted = Project.extract(state) 13 | extracted.get(setting) 14 | } 15 | 16 | def getAttribute[T](attribute: AttributeKey[T])(implicit state: State): Option[T] = { 17 | state.get(attribute) 18 | } 19 | 20 | def setAttribute[T](attribute: AttributeKey[T], value: T)(implicit state: State): State = { 21 | state.put(attribute, value) 22 | } 23 | 24 | def removeAttribute[T](attribute: AttributeKey[T])(implicit state: State): State = { 25 | state.copy(attributes = state.attributes.remove(attribute)) 26 | } 27 | 28 | def containsArg(arg: String, args: Seq[String]): Boolean = { 29 | args != null && args.exists(_.contains(arg)) 30 | } 31 | 32 | /** 33 | * Given an input argument of the format : this function will return the Option[] if it exists otherwise None 34 | * @param arg The argument name of the value to retrieve 35 | * @param args The set of arguments from the command line 36 | * @return None if the argument value is malformed or not found. Otherwise, an Option[String] with the argument value is returned. 37 | */ 38 | def getArgValue(arg: String, args: Seq[String]): Option[String] = { 39 | args 40 | .filter(a => a.contains(s"$arg:") && a.split(':').length == 2) 41 | .map(_.split(':').last) 42 | .headOption 43 | } 44 | } -------------------------------------------------------------------------------- /src/main/scala/com/tapad/docker/Version.scala: -------------------------------------------------------------------------------- 1 | package com.tapad.docker 2 | 3 | import scala.util.parsing.combinator.RegexParsers 4 | 5 | case class Version(major: Int, minor: Int, release: Int) 6 | 7 | object Version extends RegexParsers { 8 | def apply(version: String): Version = { 9 | parseVersion(version) 10 | } 11 | 12 | def parseVersion(version: String): Version = { 13 | parse(parser, version) match { 14 | case Success(ver, _) => ver 15 | case NoSuccess(msg, _) => throw new RuntimeException(s"Could not parse Version from $version: $msg") 16 | } 17 | } 18 | 19 | private val positiveWholeNumber: Parser[Int] = { 20 | ("0".r | """[1-9]?\d*""".r).map(_.toInt).withFailureMessage("non-negative integer value expected") 21 | } 22 | 23 | private val parser: Parser[Version] = { 24 | positiveWholeNumber ~ ("." ~> positiveWholeNumber) ~ ("." ~> positiveWholeNumber) ^^ { 25 | case major ~ minor ~ release => Version(major, minor, release) 26 | } 27 | } 28 | } -------------------------------------------------------------------------------- /src/test/resources/compose_1.6_format.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | 3 | services: 4 | web: 5 | image: myapp:latest 6 | networks: 7 | - front 8 | - back 9 | redis: 10 | image: redis:latest 11 | volumes: 12 | - redis-data:/var/lib/redis 13 | networks: 14 | - back 15 | test: 16 | image: test:latest 17 | 18 | volumes: 19 | redis-data: 20 | driver: flocker 21 | 22 | networks: 23 | front: 24 | driver: overlay 25 | back: 26 | driver: overlay -------------------------------------------------------------------------------- /src/test/resources/custom_network.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | 3 | services: 4 | testservice: 5 | image: testservice:0.0.1 6 | networks: 7 | - testnetwork 8 | networks: 9 | testnetwork: -------------------------------------------------------------------------------- /src/test/resources/custom_network_external.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | 3 | services: 4 | testservice: 5 | image: testservice:0.0.1 6 | networks: 7 | - testnetwork 8 | networks: 9 | testnetwork: 10 | external: true -------------------------------------------------------------------------------- /src/test/resources/custom_network_multiple.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | 3 | services: 4 | testservice: 5 | image: testservice:0.0.1 6 | networks: 7 | - testnetwork 8 | - testnetwork2 9 | networks: 10 | testnetwork: 11 | testnetwork2: -------------------------------------------------------------------------------- /src/test/resources/data/data.csv: -------------------------------------------------------------------------------- 1 | some,data 2 | -------------------------------------------------------------------------------- /src/test/resources/debug_port.yml: -------------------------------------------------------------------------------- 1 | testservice: 2 | image: testservice:latest 3 | environment: 4 | JAVA_TOOL_OPTIONS: -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=5005 5 | ports: 6 | - "0:5005" -------------------------------------------------------------------------------- /src/test/resources/debug_port_alternate_environment_format.yml: -------------------------------------------------------------------------------- 1 | testservice: 2 | image: testservice:latest 3 | environment: 4 | - JAVA_TOOL_OPTIONS=-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=5005 5 | ports: 6 | - "0:5005" -------------------------------------------------------------------------------- /src/test/resources/debug_port_not_exposed.yml: -------------------------------------------------------------------------------- 1 | testservice: 2 | image: testservice:latest 3 | environment: 4 | JAVA_TOOL_OPTIONS: -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=5005 5 | ports: 6 | - "1234" -------------------------------------------------------------------------------- /src/test/resources/debug_port_single.yml: -------------------------------------------------------------------------------- 1 | testservice: 2 | image: testservice:latest 3 | environment: 4 | JAVA_TOOL_OPTIONS: -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=5005 5 | ports: 6 | - "5005" -------------------------------------------------------------------------------- /src/test/resources/docker_inspect.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "NetworkSettings": { 4 | "Bridge": "", 5 | "SandboxID": "91b309a324b96e6043cfb9f198518f13347c3089d68cbc50440854c2d9fbf448", 6 | "HairpinMode": false, 7 | "LinkLocalIPv6Address": "", 8 | "LinkLocalIPv6PrefixLen": 0, 9 | "Ports": { 10 | "3000/tcp": [ 11 | { 12 | "HostIp": "0.0.0.0", 13 | "HostPort": "32803" 14 | } 15 | ], 16 | "3001/udp": [ 17 | { 18 | "HostIp": "0.0.0.0", 19 | "HostPort": "32802" 20 | } 21 | ], 22 | "3002/tcp": [ 23 | { 24 | "HostIp": "0.0.0.0", 25 | "HostPort": "32801" 26 | } 27 | ] 28 | }, 29 | "SandboxKey": "/var/run/docker/netns/91b309a324b9", 30 | "SecondaryIPAddresses": null, 31 | "SecondaryIPv6Addresses": null, 32 | "EndpointID": "2b53fcedce895a0d3e7bdcb81d4d46bfb971e8b722b7b0435904f9b59e34bc2e", 33 | "Gateway": "172.17.0.1", 34 | "GlobalIPv6Address": "", 35 | "GlobalIPv6PrefixLen": 0, 36 | "IPAddress": "172.17.0.2", 37 | "IPPrefixLen": 16, 38 | "IPv6Gateway": "", 39 | "MacAddress": "12:12:12:12:12:12", 40 | "Networks": { 41 | "bridge": { 42 | "EndpointID": "2b53fcedce895a0d3e7bdcb81d4d46bfb971e8b722b7b0435904f9b59e34bc2e", 43 | "Gateway": "172.17.0.1", 44 | "IPAddress": "172.17.0.2", 45 | "IPPrefixLen": 16, 46 | "IPv6Gateway": "", 47 | "GlobalIPv6Address": "", 48 | "GlobalIPv6PrefixLen": 0, 49 | "MacAddress": "12:12:12:12:12:12" 50 | } 51 | } 52 | } 53 | } 54 | ] -------------------------------------------------------------------------------- /src/test/resources/docker_inspect2.0.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "NetworkSettings": { 4 | "Bridge": "", 5 | "SandboxID": "09599d0d6575a5c1790f09fca7f9a38f89421516bb48b9b9adf868bd6bd0bb80", 6 | "HairpinMode": false, 7 | "LinkLocalIPv6Address": "", 8 | "LinkLocalIPv6PrefixLen": 0, 9 | "Ports": { 10 | "3000/tcp": [ 11 | { 12 | "HostIp": "0.0.0.0", 13 | "HostPort": "32803" 14 | } 15 | ], 16 | "3001/udp": [ 17 | { 18 | "HostIp": "0.0.0.0", 19 | "HostPort": "32802" 20 | } 21 | ], 22 | "3002/tcp": [ 23 | { 24 | "HostIp": "0.0.0.0", 25 | "HostPort": "32801" 26 | } 27 | ] 28 | }, 29 | "SandboxKey": "/var/run/docker/netns/09599d0d6575", 30 | "SecondaryIPAddresses": null, 31 | "SecondaryIPv6Addresses": null, 32 | "EndpointID": "", 33 | "Gateway": "", 34 | "GlobalIPv6Address": "", 35 | "GlobalIPv6PrefixLen": 0, 36 | "IPAddress": "", 37 | "IPPrefixLen": 0, 38 | "IPv6Gateway": "", 39 | "MacAddress": "", 40 | "Networks": { 41 | "instance_default": { 42 | "IPAMConfig": null, 43 | "Links": null, 44 | "Aliases": [ 45 | "zookeeper", 46 | "72471c8769e1" 47 | ], 48 | "NetworkID": "9a147c09c7f873c1ded7ad8743a7cf82fec1849a0827ed0dfc6c5ca33fa12a1c", 49 | "EndpointID": "5ea92039c19addeb85c32d2484e7c87b896b13e48099a0a4a688c52b308aa457", 50 | "Gateway": "172.18.0.1", 51 | "IPAddress": "172.18.0.2", 52 | "IPPrefixLen": 16, 53 | "IPv6Gateway": "", 54 | "GlobalIPv6Address": "", 55 | "GlobalIPv6PrefixLen": 0, 56 | "MacAddress": "11:11:11:11:11:11" 57 | } 58 | } 59 | } 60 | } 61 | ] -------------------------------------------------------------------------------- /src/test/resources/docker_port.txt: -------------------------------------------------------------------------------- 1 | 3000/tcp -> 0.0.0.0:32803 2 | 3001/udp -> 0.0.0.0:32802 3 | 3002/tcp -> 0.0.0.0:32801 -------------------------------------------------------------------------------- /src/test/resources/env_file.yml: -------------------------------------------------------------------------------- 1 | testservice: 2 | image: myapp:latest 3 | env_file: test.env 4 | testservice2: 5 | image: myapp2:latest 6 | env_file: 7 | - test.env 8 | - test2.env -------------------------------------------------------------------------------- /src/test/resources/env_file_invalid.yml: -------------------------------------------------------------------------------- 1 | testservice: 2 | image: myapp:latest 3 | env_file: doesnotexist.env -------------------------------------------------------------------------------- /src/test/resources/localbuild_tag.yml: -------------------------------------------------------------------------------- 1 | testservice: 2 | image: testservice:latest 3 | ports: 4 | - "0:3000" -------------------------------------------------------------------------------- /src/test/resources/multi_service_no_tags.yml: -------------------------------------------------------------------------------- 1 | testservice1: 2 | image: testservice1:latest 3 | ports: 4 | - "0:3000" 5 | testservice2: 6 | image: testservice2:latest 7 | ports: 8 | - "0:3000" -------------------------------------------------------------------------------- /src/test/resources/no_custom_tags.yml: -------------------------------------------------------------------------------- 1 | testservice: 2 | image: testservice:latest 3 | ports: 4 | - "0:3000" -------------------------------------------------------------------------------- /src/test/resources/no_exposed_ports.yml: -------------------------------------------------------------------------------- 1 | testservice: 2 | image: testservice:latest 3 | environment: 4 | JAVA_TOOL_OPTIONS: -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=5005 -------------------------------------------------------------------------------- /src/test/resources/port_expansion.yml: -------------------------------------------------------------------------------- 1 | testservice: 2 | image: testservice:0.0.1 3 | ports: 4 | - "1000" 5 | - "2000-2002" 6 | - "0:3000-3002" 7 | - "5000-5002:4000-4002" 8 | - "6000-6001:6000-6001" -------------------------------------------------------------------------------- /src/test/resources/port_expansion_invalid.yml: -------------------------------------------------------------------------------- 1 | testservice: 2 | image: testservice:0.0.1 3 | ports: 4 | - "4000-4002:4000-4003" -------------------------------------------------------------------------------- /src/test/resources/skippull_tag.yml: -------------------------------------------------------------------------------- 1 | testservice: 2 | image: testservice:latest 3 | ports: 4 | - "0:3000" -------------------------------------------------------------------------------- /src/test/resources/sort.yml: -------------------------------------------------------------------------------- 1 | testserviceB: 2 | image: testservice:latest 3 | environment: 4 | JAVA_TOOL_OPTIONS: -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=5005 5 | ports: 6 | - "0:5005" 7 | - "0:80" 8 | - "0:10000" 9 | - "0:8000/udp" 10 | testserviceA: 11 | image: testserviceA:latest 12 | environment: 13 | JAVA_TOOL_OPTIONS: -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=5005 14 | ports: 15 | - "0:5005" 16 | - "0:2003" 17 | - "0:12345" -------------------------------------------------------------------------------- /src/test/resources/test.env: -------------------------------------------------------------------------------- 1 | TEST_ENV=value -------------------------------------------------------------------------------- /src/test/resources/test2.env: -------------------------------------------------------------------------------- 1 | TEST_ENV2=value2 -------------------------------------------------------------------------------- /src/test/resources/unsupported_field_build.yml: -------------------------------------------------------------------------------- 1 | testservice: 2 | build: /path/to/build/dir 3 | environment: 4 | JAVA_TOOL_OPTIONS: -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=5005 5 | ports: 6 | - "0:5005" -------------------------------------------------------------------------------- /src/test/resources/unsupported_field_container_name.yml: -------------------------------------------------------------------------------- 1 | testservice: 2 | image: testservice:latest 3 | container_name: my-test-container 4 | environment: 5 | JAVA_TOOL_OPTIONS: -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=5005 6 | ports: 7 | - "0:5005" -------------------------------------------------------------------------------- /src/test/resources/unsupported_field_extends.yml: -------------------------------------------------------------------------------- 1 | testservice: 2 | extends: 3 | file: debug_port.yml 4 | service: testservice 5 | ports: 6 | - "8000:8000" -------------------------------------------------------------------------------- /src/test/resources/variable_substitution.yml: -------------------------------------------------------------------------------- 1 | testservice: 2 | image: testservice:0.0.1 3 | ports: 4 | - "${SOURCE_PORT}:5005" -------------------------------------------------------------------------------- /src/test/resources/variable_substitution_default_value.yml: -------------------------------------------------------------------------------- 1 | testservice: 2 | image: testservice:${APP_DOCKER_IMAGE_TAG:-latest} 3 | ports: 4 | - "${SOURCE_PORT:-6666}:5005" 5 | - "${SOURCE_PORT2}:5006" 6 | - "${SOURCE_PORT3}:5007" -------------------------------------------------------------------------------- /src/test/resources/version_number.yml: -------------------------------------------------------------------------------- 1 | testservice: 2 | image: testservice:0.0.1 3 | ports: 4 | - "0:3000" -------------------------------------------------------------------------------- /src/test/resources/volumes.yml: -------------------------------------------------------------------------------- 1 | testservice: 2 | image: testservice:0.0.1 3 | volumes: 4 | - ./data:/data 5 | - /absolute/path/1 6 | - /absolute/path/2:/mounted/elsewhere 7 | -------------------------------------------------------------------------------- /src/test/resources/volumes_access_level.yml: -------------------------------------------------------------------------------- 1 | version: '2' 2 | services: 3 | testservice: 4 | image: testservice:0.0.1 5 | volumes: 6 | - ./data:/data:ro 7 | - /absolute/path/2:/mounted/elsewhere:rw 8 | -------------------------------------------------------------------------------- /src/test/scala/ComposeFileProcessingSpec.scala: -------------------------------------------------------------------------------- 1 | import java.io.File 2 | import java.nio.file.Paths 3 | import java.util 4 | 5 | import com.tapad.docker.DockerComposeKeys._ 6 | import com.tapad.docker.DockerComposePlugin._ 7 | import com.tapad.docker.{ ComposeFile, ComposeFileFormatException, DockerComposePluginLocal } 8 | import org.mockito.Matchers._ 9 | import org.mockito.Mockito._ 10 | import org.scalatest.mockito.MockitoSugar 11 | import org.scalatest.{ BeforeAndAfter, FunSuite, OneInstancePerTest } 12 | import sbt.Keys._ 13 | 14 | class ComposeFileProcessingSpec extends FunSuite with BeforeAndAfter with OneInstancePerTest with MockitoSugar { 15 | 16 | test("Validate Compose field 'build:' results in correct exception thrown and error message printing") { 17 | val (composeMock, composeFilePath) = getComposeFileMock("unsupported_field_build.yml") 18 | val composeYaml = composeMock.readComposeFile(composeFilePath) 19 | 20 | val thrown = intercept[ComposeFileFormatException] { 21 | composeMock.processCustomTags(null, null, composeYaml) 22 | } 23 | assert(thrown.getMessage == getUnsupportedFieldErrorMsg("build")) 24 | } 25 | 26 | test("Validate Compose field 'container_name:' results in correct exception thrown and error message printing") { 27 | val (composeMock, composeFilePath) = getComposeFileMock("unsupported_field_container_name.yml") 28 | val composeYaml = composeMock.readComposeFile(composeFilePath) 29 | 30 | val thrown = intercept[ComposeFileFormatException] { 31 | composeMock.processCustomTags(null, null, composeYaml) 32 | } 33 | assert(thrown.getMessage == getUnsupportedFieldErrorMsg("container_name")) 34 | } 35 | 36 | test("Validate Compose field 'extends:' results in correct exception thrown and error message printing") { 37 | val (composeMock, composeFilePath) = getComposeFileMock("unsupported_field_extends.yml") 38 | val composeYaml = composeMock.readComposeFile(composeFilePath) 39 | 40 | val thrown = intercept[ComposeFileFormatException] { 41 | composeMock.processCustomTags(null, null, composeYaml) 42 | } 43 | assert(thrown.getMessage == getUnsupportedFieldErrorMsg("extends")) 44 | } 45 | 46 | test("Validate Compose tag results in 'build' image source and custom tag is removed") { 47 | val (composeMock, composeFilePath) = getComposeFileMock("localbuild_tag.yml", serviceName = "nomatch") 48 | val composeYaml = composeMock.readComposeFile(composeFilePath) 49 | val serviceInfo = composeMock.processCustomTags(null, Seq.empty, composeYaml) 50 | 51 | assert(serviceInfo.exists(service => service.imageName == "testservice:latest" && service.imageSource == buildImageSource)) 52 | } 53 | 54 | test("Validate Compose tag results in 'cache' image source and custom tag is removed") { 55 | val (composeMock, composeFilePath) = getComposeFileMock("skippull_tag.yml", serviceName = "nomatch") 56 | val composeYaml = composeMock.readComposeFile(composeFilePath) 57 | val serviceInfo = composeMock.processCustomTags(null, Seq.empty, composeYaml) 58 | 59 | assert(serviceInfo.exists(service => service.imageName == "testservice:latest" && service.imageSource == cachedImageSource)) 60 | } 61 | 62 | test("Validate Compose service name match results in 'build' image source") { 63 | val (composeMock, composeFilePath) = getComposeFileMock("no_custom_tags.yml") 64 | val composeYaml = composeMock.readComposeFile(composeFilePath) 65 | val serviceInfo = composeMock.processCustomTags(null, Seq.empty, composeYaml) 66 | 67 | assert(serviceInfo.exists(service => service.imageName == "testservice:latest" && service.imageSource == buildImageSource)) 68 | } 69 | 70 | test("Validate Compose no matching compose service name results in 'defined' image source") { 71 | val (composeMock, composeFilePath) = getComposeFileMock("no_custom_tags.yml", serviceName = "nomatch") 72 | val composeYaml = composeMock.readComposeFile(composeFilePath) 73 | val serviceInfo = composeMock.processCustomTags(null, Seq.empty, composeYaml) 74 | 75 | assert(serviceInfo.exists(service => service.imageName == "testservice:latest" && service.imageSource == definedImageSource)) 76 | } 77 | 78 | test("Validate Compose 'composeNoBuild' setting results in image source 'defined' even if service matches the composeServiceName") { 79 | val (composeMock, composeFilePath) = getComposeFileMock("no_custom_tags.yml", noBuild = true) 80 | val composeYaml = composeMock.readComposeFile(composeFilePath) 81 | val serviceInfo = composeMock.processCustomTags(null, Seq.empty, composeYaml) 82 | 83 | assert(serviceInfo.exists(service => service.imageName == "testservice:latest" && service.imageSource == definedImageSource)) 84 | } 85 | 86 | test("Validate Compose 'skipPull' command line argument results in all non-build image sources as 'cache'") { 87 | val (composeMock, composeFilePath) = getComposeFileMock("multi_service_no_tags.yml", serviceName = "nomatch") 88 | val composeYaml = composeMock.readComposeFile(composeFilePath) 89 | val serviceInfo = composeMock.processCustomTags(null, Seq(skipPullArg), composeYaml) 90 | 91 | assert(serviceInfo.size == 2) 92 | assert(serviceInfo.forall(_.imageSource == cachedImageSource)) 93 | } 94 | 95 | test("Validate Compose 'skipPull' command line argument still leaves the matching compose image as 'build'") { 96 | val newServiceName = "testservice1" 97 | val (composeMock, composeFilePath) = getComposeFileMock("multi_service_no_tags.yml", serviceName = newServiceName) 98 | val composeYaml = composeMock.readComposeFile(composeFilePath) 99 | val serviceInfo = composeMock.processCustomTags(null, Seq(skipPullArg), composeYaml) 100 | 101 | assert(serviceInfo.size == 2) 102 | assert(serviceInfo.exists(service => service.imageName == s"$newServiceName:latest" && service.imageSource == buildImageSource)) 103 | } 104 | 105 | test("Validate Compose 'skipBuild' command line argument still leaves the matching compose image as 'build'") { 106 | val newServiceName = "testservice1" 107 | val (composeMock, composeFilePath) = getComposeFileMock("multi_service_no_tags.yml", serviceName = newServiceName) 108 | val composeYaml = composeMock.readComposeFile(composeFilePath) 109 | val serviceInfo = composeMock.processCustomTags(null, Seq(skipPullArg), composeYaml) 110 | 111 | assert(serviceInfo.size == 2) 112 | assert(serviceInfo.exists(service => service.imageName == s"$newServiceName:latest" && service.imageSource == buildImageSource)) 113 | } 114 | 115 | test("Validate Compose image version number is updated to sbt 'version' when not blank or defined as 'latest'") { 116 | val newVersion = "9.9.9" 117 | val (composeMock, composeFilePath) = getComposeFileMock("version_number.yml", versionNumber = newVersion) 118 | val composeYaml = composeMock.readComposeFile(composeFilePath) 119 | val serviceInfo = composeMock.processCustomTags(null, Seq.empty, composeYaml) 120 | 121 | assert(serviceInfo.exists(service => service.imageName == s"testservice:$newVersion" && service.imageSource == buildImageSource)) 122 | } 123 | 124 | test("Validate the correct Debug port is found when supplied in the 'host:container' format") { 125 | val (composeMock, composeFilePath) = getComposeFileMock("debug_port.yml") 126 | val composeYaml = composeMock.readComposeFile(composeFilePath) 127 | val serviceInfo = composeMock.processCustomTags(null, Seq.empty, composeYaml) 128 | 129 | assert(serviceInfo.exists(service => service.imageName == "testservice:latest" && 130 | service.ports.exists(_.isDebug) && 131 | service.ports.exists(_.containerPort == "5005"))) 132 | } 133 | 134 | test("Validate the correct Debug port is found when supplied in the 'host' format") { 135 | val (composeMock, composeFilePath) = getComposeFileMock("debug_port_single.yml") 136 | val composeYaml = composeMock.readComposeFile(composeFilePath) 137 | val serviceInfo = composeMock.processCustomTags(null, Seq.empty, composeYaml) 138 | 139 | assert(serviceInfo.exists(service => service.imageName == "testservice:latest" && 140 | service.ports.exists(_.isDebug) && 141 | service.ports.exists(_.containerPort == "5005"))) 142 | } 143 | 144 | test("Validate the correct Debug port is found when the alternate 'environment:' field format is used") { 145 | val (composeMock, composeFilePath) = getComposeFileMock("debug_port_alternate_environment_format.yml") 146 | val composeYaml = composeMock.readComposeFile(composeFilePath) 147 | val serviceInfo = composeMock.processCustomTags(null, Seq.empty, composeYaml) 148 | 149 | assert(serviceInfo.exists(service => service.imageName == "testservice:latest" && 150 | service.ports.exists(_.isDebug) && 151 | service.ports.exists(_.containerPort == "5005"))) 152 | } 153 | 154 | test("Validate the Debug port is not found when not exposed") { 155 | val (composeMock, composeFilePath) = getComposeFileMock("debug_port_not_exposed.yml") 156 | val composeYaml = composeMock.readComposeFile(composeFilePath) 157 | val serviceInfo = composeMock.processCustomTags(null, Seq.empty, composeYaml) 158 | 159 | assert(serviceInfo.exists(service => service.imageName == "testservice:latest" && 160 | service.ports.exists(_.isDebug == false) && 161 | service.ports.exists(_.containerPort == "1234"))) 162 | } 163 | 164 | test("Validate that the 1.6 Docker Compose file format can be properly processed") { 165 | val (composeMock, composeFilePath) = getComposeFileMock("compose_1.6_format.yml", serviceName = "nomatch") 166 | val composeYaml = composeMock.readComposeFile(composeFilePath) 167 | val serviceInfo = composeMock.processCustomTags(null, Seq.empty, composeYaml) 168 | 169 | assert(serviceInfo.exists(service => service.imageName == "myapp:latest" && service.imageSource == cachedImageSource)) 170 | assert(serviceInfo.exists(service => service.imageName == "redis:latest" && service.imageSource == buildImageSource)) 171 | assert(serviceInfo.exists(service => service.imageName == "test:latest" && service.imageSource == definedImageSource)) 172 | } 173 | 174 | test("Validate that Docker Compose file port ranges are expanded") { 175 | val (composeMock, composeFilePath) = getComposeFileMock("port_expansion.yml") 176 | val composeYaml = composeMock.readComposeFile(composeFilePath) 177 | val serviceInfo = composeMock.processCustomTags(null, Seq.empty, composeYaml) 178 | 179 | assert(serviceInfo.exists(service => 180 | service.ports.exists(p => p.hostPort == "1000" && p.containerPort == "1000") && 181 | service.ports.exists(p => p.hostPort == "2000" && p.containerPort == "2000") && 182 | service.ports.exists(p => p.hostPort == "2001" && p.containerPort == "2001") && 183 | service.ports.exists(p => p.hostPort == "2002" && p.containerPort == "2002") && 184 | service.ports.exists(p => p.hostPort == "3000" && p.containerPort == "3000") && 185 | service.ports.exists(p => p.hostPort == "3001" && p.containerPort == "3001") && 186 | service.ports.exists(p => p.hostPort == "3002" && p.containerPort == "3002") && 187 | service.ports.exists(p => p.hostPort == "5000" && p.containerPort == "4000") && 188 | service.ports.exists(p => p.hostPort == "5001" && p.containerPort == "4001") && 189 | service.ports.exists(p => p.hostPort == "5002" && p.containerPort == "4002") && 190 | service.ports.exists(p => p.hostPort == "6000" && p.containerPort == "6000") && 191 | service.ports.exists(p => p.hostPort == "6001" && p.containerPort == "6001"))) 192 | } 193 | 194 | test("Validate that an improper port range throws an exception") { 195 | val (composeMock, composeFilePath) = getComposeFileMock("port_expansion_invalid.yml") 196 | val composeYaml = composeMock.readComposeFile(composeFilePath) 197 | intercept[IllegalStateException] { 198 | composeMock.processCustomTags(null, Seq.empty, composeYaml) 199 | } 200 | } 201 | 202 | test("Validate that relative volume settings are updated with the fully qualified path") { 203 | val (composeMock, composeFilePath) = getComposeFileMock("volumes.yml") 204 | val composeFileDir = composeFilePath.substring(0, composeFilePath.lastIndexOf(File.separator)) 205 | 206 | val composeYaml = composeMock.readComposeFile(composeFilePath) 207 | composeMock.processCustomTags(null, Seq.empty, composeYaml) 208 | val modifiedVolumesPaths = composeYaml.filter(_._1 == "testservice").head._2.get(composeMock.volumesKey).asInstanceOf[util.List[String]] 209 | assert(modifiedVolumesPaths.size() == 3) 210 | assert(modifiedVolumesPaths.get(0) == s"$composeFileDir${File.separator}data:/data") 211 | assert(modifiedVolumesPaths.get(1) == "/absolute/path/1") 212 | assert(modifiedVolumesPaths.get(2) == "/absolute/path/2:/mounted/elsewhere") 213 | } 214 | 215 | test("Validate that relative volume settings with access specifiers are updated with the fully qualified path") { 216 | val (composeMock, composeFilePath) = getComposeFileMock("volumes_access_level.yml") 217 | val composeFileDir = composeFilePath.substring(0, composeFilePath.lastIndexOf(File.separator)) 218 | 219 | val composeYaml = composeMock.readComposeFile(composeFilePath) 220 | composeMock.processCustomTags(null, Seq.empty, composeYaml) 221 | val composeServicesYaml = getComposeFileServices(composeYaml) 222 | val modifiedVolumesPaths = composeServicesYaml.filter(_._1 == "testservice").head._2.get(composeMock.volumesKey).asInstanceOf[util.List[String]] 223 | assert(modifiedVolumesPaths.size() == 2) 224 | assert(modifiedVolumesPaths.get(0) == s"$composeFileDir${File.separator}data:/data:ro") 225 | assert(modifiedVolumesPaths.get(1) == "/absolute/path/2:/mounted/elsewhere:rw") 226 | } 227 | 228 | test("Validate that the env_file settings gets updated with the fully qualified path") { 229 | val (composeMock, composeFilePath) = getComposeFileMock("env_file.yml") 230 | val composeFileDir = composeFilePath.substring(0, composeFilePath.lastIndexOf(File.separator)) 231 | 232 | val composeYaml = composeMock.readComposeFile(composeFilePath) 233 | composeMock.processCustomTags(null, Seq.empty, composeYaml) 234 | val modifiedEnvPath = composeYaml.filter(_._1 == "testservice").head._2.get(composeMock.envFileKey) 235 | assert(modifiedEnvPath == s"$composeFileDir${File.separator}test.env") 236 | 237 | val modifiedEnvPath2 = composeYaml.filter(_._1 == "testservice2").head._2.get(composeMock.envFileKey).asInstanceOf[util.List[String]] 238 | assert(modifiedEnvPath2.size() == 2) 239 | assert(modifiedEnvPath2.get(0) == s"$composeFileDir${File.separator}test.env") 240 | assert(modifiedEnvPath2.get(1) == s"$composeFileDir${File.separator}test2.env") 241 | } 242 | 243 | test("Validate that an env_file file that cannot be found fails with an exception") { 244 | val (composeMock, composeFilePath) = getComposeFileMock("env_file_invalid.yml") 245 | doReturn(composeFilePath).when(composeMock).getSetting(composeFile)(null) 246 | 247 | val composeYaml = composeMock.readComposeFile(composeFilePath) 248 | intercept[IllegalStateException] { 249 | composeMock.processCustomTags(null, Seq.empty, composeYaml) 250 | } 251 | } 252 | 253 | test("Validate that docker-compose variables are substituted") { 254 | val (composeMock, composeFilePath) = getComposeFileMock("variable_substitution.yml") 255 | doReturn(composeFilePath).when(composeMock).getSetting(composeFile)(null) 256 | 257 | val composeYaml = composeMock.readComposeFile(composeFilePath, Vector(("SOURCE_PORT", "5555"))) 258 | 259 | val ports = composeYaml("testservice").get("ports").asInstanceOf[util.ArrayList[String]].get(0) 260 | assert(ports == "5555:5005") 261 | } 262 | 263 | test("Validate that docker-compose variables are substituted properly when default values are provided") { 264 | val (composeMock, composeFilePath) = getComposeFileMock("variable_substitution_default_value.yml") 265 | doReturn(composeFilePath).when(composeMock).getSetting(composeFile)(null) 266 | 267 | val composeYaml = composeMock.readComposeFile(composeFilePath, Vector(("SOURCE_PORT", "5555"), ("SOURCE_PORT2", "7777"))) 268 | 269 | val yaml = composeYaml("testservice") 270 | assert(yaml.get("image") == "testservice:latest") 271 | 272 | val ports = yaml.get("ports").asInstanceOf[util.ArrayList[String]] 273 | assert(ports.get(0) == "5555:5005") 274 | assert(ports.get(1) == "7777:5006") 275 | assert(ports.get(2) == "${SOURCE_PORT3}:5007") 276 | } 277 | 278 | test("Validate that the list of static port mappings is fetched when '-useStaticPorts' argument is supplied") { 279 | val (composeMock, composeFilePath) = getComposeFileMock("port_expansion.yml") 280 | val composeYaml = composeMock.readComposeFile(composeFilePath) 281 | composeMock.processCustomTags(null, Seq("-useStaticPorts"), composeYaml) 282 | 283 | //Validate that the list of static port mappings is fetched once 284 | verify(composeMock, times(1)).getStaticPortMappings(any[Seq[String]]) 285 | } 286 | 287 | test("Validate that the proper creation of a list of static port mappings when '-useStaticPorts' argument is supplied ") { 288 | val composeMock = spy(new DockerComposePluginLocal) 289 | val list = composeMock.getStaticPortMappings(Seq("1000", "2000", "0:3000", "5000:4000")) 290 | 291 | assert(list.size() == 4 && 292 | list.contains("1000:1000") && 293 | list.contains("2000:2000") && 294 | list.contains("3000:3000") && 295 | list.contains("5000:4000")) 296 | } 297 | 298 | test("Validate that port conflicts are properly handled when '-useStaticPorts' argument is supplied") { 299 | val (composeMock, composeFilePath) = getComposeFileMock("multi_service_no_tags.yml") 300 | val composeYaml = composeMock.readComposeFile(composeFilePath) 301 | val serviceInfo = composeMock.processCustomTags(null, Seq("-useStaticPorts"), composeYaml) 302 | 303 | val portsList1 = composeYaml("testservice1").get("ports").asInstanceOf[java.util.ArrayList[String]] 304 | val portsList2 = composeYaml("testservice2").get("ports").asInstanceOf[java.util.ArrayList[String]] 305 | 306 | //Validate that the static port mapping is used for the first service and dynamically assigned host port for the second one 307 | assert(portsList1.size() == 1 && 308 | portsList1.contains("3000:3000") && 309 | portsList2.size() == 1 && 310 | portsList2.contains("0:3000")) 311 | } 312 | 313 | test("Validate that port range in Compose file are properly handled when '-useStaticPorts' argument is supplied") { 314 | val (composeMock, composeFilePath) = getComposeFileMock("port_expansion.yml") 315 | val composeYaml = composeMock.readComposeFile(composeFilePath) 316 | val serviceInfo = composeMock.processCustomTags(null, Seq("-useStaticPorts"), composeYaml) 317 | 318 | val portsList = composeYaml("testservice").get("ports").asInstanceOf[java.util.ArrayList[String]] 319 | 320 | assert(portsList.size() == 12 && 321 | portsList.contains("1000:1000") && 322 | portsList.contains("2000:2000") && 323 | portsList.contains("2001:2001") && 324 | portsList.contains("2002:2002") && 325 | portsList.contains("3000:3000") && 326 | portsList.contains("3001:3001") && 327 | portsList.contains("3002:3002") && 328 | portsList.contains("5000:4000") && 329 | portsList.contains("5001:4001") && 330 | portsList.contains("5002:4002") && 331 | portsList.contains("6000:6000") && 332 | portsList.contains("6001:6001")) 333 | 334 | } 335 | 336 | test("Validate that a single custom internal network is property detected in the Compose file ") { 337 | val (composeMock, composeFilePath) = getComposeFileMock("custom_network.yml") 338 | val composeYaml = composeMock.readComposeFile(composeFilePath) 339 | 340 | val detectedNetworks = composeMock.composeInternalNetworkNames(composeYaml) 341 | 342 | assert(detectedNetworks.length == 1) 343 | assert(detectedNetworks.contains("testnetwork")) 344 | } 345 | 346 | test("Validate that custom external networks are ignored when processing the Compose file ") { 347 | val (composeMock, composeFilePath) = getComposeFileMock("custom_network_external.yml") 348 | val composeYaml = composeMock.readComposeFile(composeFilePath) 349 | 350 | val detectedNetworks = composeMock.composeInternalNetworkNames(composeYaml) 351 | 352 | assert(detectedNetworks.isEmpty) 353 | } 354 | 355 | test("Validate that multiple internal networks are detected when processing the Compose file ") { 356 | val (composeMock, composeFilePath) = getComposeFileMock("custom_network_multiple.yml") 357 | val composeYaml = composeMock.readComposeFile(composeFilePath) 358 | 359 | val detectedNetworks = composeMock.composeInternalNetworkNames(composeYaml) 360 | 361 | assert(detectedNetworks.length == 2) 362 | assert(detectedNetworks.contains("testnetwork")) 363 | assert(detectedNetworks.contains("testnetwork2")) 364 | } 365 | 366 | /** 367 | * @return tuple of a mocked ComposeFile, and the path to that file 368 | */ 369 | def getComposeFileMock( 370 | composeFileName: String, 371 | serviceName: String = "testservice", 372 | versionNumber: String = "1.0.0", 373 | noBuild: Boolean = false 374 | ): (ComposeFile, String) = { 375 | val composeMock = spy(new DockerComposePluginLocal) 376 | 377 | val composeFilePath = Paths.get(getClass.getResource(composeFileName).toURI).toString 378 | doReturn(composeFilePath).when(composeMock).getSetting(composeFile)(null) 379 | 380 | doReturn(serviceName).when(composeMock).getSetting(composeServiceName)(null) 381 | doReturn(versionNumber).when(composeMock).getSetting(version)(null) 382 | doReturn(noBuild).when(composeMock).getSetting(composeNoBuild)(null) 383 | doReturn(false).when(composeMock).getSetting(suppressColorFormatting)(null) 384 | doReturn(versionNumber).when(composeMock).getComposeServiceVersion(null) 385 | 386 | (composeMock, composeFilePath) 387 | } 388 | 389 | } 390 | -------------------------------------------------------------------------------- /src/test/scala/ComposeInstancesSpec.scala: -------------------------------------------------------------------------------- 1 | import sbt._ 2 | import com.tapad.docker.{ DockerComposePluginLocal, RunningInstanceInfo, Version } 3 | import org.mockito.Matchers._ 4 | import org.mockito.Mockito._ 5 | import org.scalatest.{ BeforeAndAfter, FunSuite, OneInstancePerTest } 6 | 7 | class ComposeInstancesSpec extends FunSuite with BeforeAndAfter with OneInstancePerTest with MockHelpers { 8 | test("Validate that no instances are printed when none are running") { 9 | val composeMock = spy(new DockerComposePluginLocal) 10 | val serviceName = "matchingservice" 11 | 12 | mockDockerCommandCalls(composeMock) 13 | mockSystemSettings(composeMock, serviceName, None) 14 | 15 | composeMock.printDockerComposeInstances(null, null) 16 | 17 | verify(composeMock, times(0)).printMappedPortInformation(any[State], any[RunningInstanceInfo], any[Version]) 18 | } 19 | 20 | test("Validate that multiple instances across sbt projects are printed when they are running") { 21 | val composeMock = spy(new DockerComposePluginLocal) 22 | val serviceName = "matchingservice" 23 | val instance1 = RunningInstanceInfo("instanceName1", serviceName, "path", List.empty) 24 | val instance2 = RunningInstanceInfo("instanceName2", serviceName, "path", List.empty) 25 | val instance3 = RunningInstanceInfo("instanceName3", "nonSbtProjectService", "path", List.empty) 26 | 27 | mockDockerCommandCalls(composeMock) 28 | mockSystemSettings(composeMock, serviceName, Some(List(instance1, instance2, instance3))) 29 | 30 | composeMock.printDockerComposeInstances(null, null) 31 | 32 | verify(composeMock, times(3)).printMappedPortInformation(any[State], any[RunningInstanceInfo], any[Version]) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/test/scala/ImageBuildingSpec.scala: -------------------------------------------------------------------------------- 1 | import com.tapad.docker.DockerComposeKeys._ 2 | import com.tapad.docker.DockerComposePlugin._ 3 | import com.tapad.docker.DockerComposePluginLocal 4 | import org.mockito.Mockito._ 5 | import org.scalatest.{ OneInstancePerTest, BeforeAndAfter, FunSuite } 6 | 7 | class ImageBuildingSpec extends FunSuite with BeforeAndAfter with OneInstancePerTest { 8 | test("Validate that a Docker image is built when 'skipBuild' and 'noBuild' are not set") { 9 | val composeMock = spy(new DockerComposePluginLocal) 10 | 11 | doReturn(false).when(composeMock).getSetting(suppressColorFormatting)(null) 12 | doReturn(false).when(composeMock).getSetting(composeNoBuild)(null) 13 | doNothing().when(composeMock).buildDockerImageTask(null) 14 | 15 | composeMock.buildDockerImage(null, null) 16 | 17 | verify(composeMock, times(1)).buildDockerImageTask(null) 18 | } 19 | 20 | test("Validate that a Docker image is not built when 'skipBuild' is passed as an argument") { 21 | val composeMock = spy(new DockerComposePluginLocal) 22 | 23 | doReturn(false).when(composeMock).getSetting(suppressColorFormatting)(null) 24 | doReturn(false).when(composeMock).getSetting(composeNoBuild)(null) 25 | doNothing().when(composeMock).buildDockerImageTask(null) 26 | 27 | composeMock.buildDockerImage(null, Seq(skipBuildArg)) 28 | 29 | verify(composeMock, times(0)).buildDockerImageTask(null) 30 | } 31 | 32 | test("Validate that a Docker image is not built when the 'noBuild' setting is true") { 33 | val composeMock = spy(new DockerComposePluginLocal) 34 | 35 | doReturn(false).when(composeMock).getSetting(suppressColorFormatting)(null) 36 | doReturn(true).when(composeMock).getSetting(composeNoBuild)(null) 37 | doNothing().when(composeMock).buildDockerImageTask(null) 38 | 39 | composeMock.buildDockerImage(null, null) 40 | 41 | verify(composeMock, times(0)).buildDockerImageTask(null) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/test/scala/ImagePullingSpec.scala: -------------------------------------------------------------------------------- 1 | import com.tapad.docker.DockerComposePlugin._ 2 | import com.tapad.docker.{ ServiceInfo, DockerComposePluginLocal } 3 | import org.mockito.Mockito._ 4 | import org.scalatest.{ OneInstancePerTest, BeforeAndAfter, FunSuite } 5 | 6 | class ImagePullingSpec extends FunSuite with BeforeAndAfter with OneInstancePerTest { 7 | test("Validate that when the 'skipPull' argument is passed in no imaged are pull from the Docker registry") { 8 | val instanceMock = new DockerComposePluginLocal with MockOutput 9 | 10 | instanceMock.pullDockerImages(Seq(skipPullArg), null, suppressColor = false) 11 | assert(instanceMock.messages.exists(_.contains("Skipping Docker Repository Pull for all images."))) 12 | } 13 | 14 | test("Validate that images with a 'build' source not pulled from the Docker registry") { 15 | val instanceMock = new DockerComposePluginLocal with MockOutput 16 | val imageName = "buildImageName" 17 | val serviceInfo = ServiceInfo("serviceName", imageName, buildImageSource, null) 18 | 19 | instanceMock.pullDockerImages(null, List(serviceInfo), suppressColor = false) 20 | assert(instanceMock.messages.contains(s"Skipping Pull of image: $imageName")) 21 | } 22 | 23 | test("Validate that images with a 'defined' source are pulled from the Docker registry") { 24 | val instanceMock = spy(new DockerComposePluginLocal) 25 | val imageName = "buildImageName" 26 | val serviceInfo = ServiceInfo("serviceName", imageName, definedImageSource, null) 27 | 28 | doNothing().when(instanceMock).dockerPull(imageName) 29 | 30 | instanceMock.pullDockerImages(null, List(serviceInfo), suppressColor = false) 31 | 32 | verify(instanceMock, times(1)).dockerPull(imageName) 33 | } 34 | 35 | test("Validate that images with a 'cache' source are not pulled from the Docker registry") { 36 | val instanceMock = new DockerComposePluginLocal with MockOutput 37 | val imageName = "cacheImageName" 38 | val serviceInfo = ServiceInfo("serviceName", imageName, cachedImageSource, null) 39 | 40 | instanceMock.pullDockerImages(null, List(serviceInfo), suppressColor = false) 41 | assert(instanceMock.messages.contains(s"Skipping Pull of image: $imageName")) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/test/scala/InstancePersistenceSpec.scala: -------------------------------------------------------------------------------- 1 | import com.tapad.docker.{ RunningInstanceInfo, DockerComposePluginLocal } 2 | import com.tapad.docker.DockerComposeKeys._ 3 | import org.mockito.Mockito._ 4 | import org.scalatest.mockito.MockitoSugar 5 | import org.scalatest.{ BeforeAndAfter, FunSuite, OneInstancePerTest } 6 | 7 | class InstancePersistenceSpec extends FunSuite with BeforeAndAfter with OneInstancePerTest with MockitoSugar { 8 | 9 | test("Validate that only running instances from this sbt session are returned") { 10 | val instanceMock = spy(new DockerComposePluginLocal) 11 | 12 | val runningInstanceMatch = RunningInstanceInfo("instanceNameMatch", "matchingservice", "composePath", List.empty) 13 | val runningInstanceNoMatch = RunningInstanceInfo("instanceNameNoMatch", "nomatchingservice", "composePath", List.empty) 14 | 15 | doReturn("matchingservice").when(instanceMock).getSetting(composeServiceName)(null) 16 | doReturn(Option(List(runningInstanceMatch, runningInstanceNoMatch))).when(instanceMock).getAttribute(runningInstances)(null) 17 | 18 | val instanceIds = instanceMock.getServiceRunningInstanceIds(null) 19 | 20 | assert(instanceIds.size == 1) 21 | assert(instanceIds.contains("instanceNameMatch")) 22 | } 23 | 24 | test("Validate that only matching instance ids are returned") { 25 | val instanceMock = spy(new DockerComposePluginLocal) 26 | 27 | val runningInstanceMatch = RunningInstanceInfo("instanceNameMatch", "matchingservice", "composePath", List.empty) 28 | val runningInstanceNoMatch = RunningInstanceInfo("instanceNameNoMatch", "nomatchingservice", "composePath", List.empty) 29 | 30 | doReturn("matchingservice").when(instanceMock).getSetting(composeServiceName)(null) 31 | doReturn(Option(List(runningInstanceMatch, runningInstanceNoMatch))).when(instanceMock).getAttribute(runningInstances)(null) 32 | 33 | val instance = instanceMock.getMatchingRunningInstance(null, Seq("instanceNameMatch")) 34 | 35 | assert(instance.isDefined) 36 | assert(instance.get.instanceName == "instanceNameMatch") 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/test/scala/InstanceRestartingSpec.scala: -------------------------------------------------------------------------------- 1 | import com.tapad.docker.DockerComposeKeys._ 2 | import com.tapad.docker._ 3 | import org.mockito.Matchers._ 4 | import org.mockito.Mockito._ 5 | import org.scalatest.{ FunSuite, OneInstancePerTest } 6 | import scala.collection.Iterable 7 | 8 | class InstanceRestartingSpec extends FunSuite with OneInstancePerTest with MockHelpers { 9 | 10 | test("Validate the correct type of exception thrown and error message shown when restarting a non existent instance") { 11 | val instanceId1 = "123456" 12 | val instanceId2 = "987654" 13 | val instanceIdNonExistent = "111111" 14 | val serviceName = "service" 15 | val composePath = "path" 16 | 17 | val instance1 = RunningInstanceInfo(instanceId1, serviceName, composePath, List.empty) 18 | val instance2 = RunningInstanceInfo(instanceId2, serviceName, composePath, List.empty) 19 | 20 | val composeMock = getComposeMock(serviceName) 21 | mockDockerCommandCalls(composeMock) 22 | mockSystemSettings(composeMock, serviceName, Some(List(instance1, instance2))) 23 | 24 | val thrown = intercept[IllegalArgumentException] { 25 | composeMock.restartInstancePrecheck(null, Seq(instanceIdNonExistent)) 26 | } 27 | 28 | assert(thrown.getMessage == "No local Docker Compose instances found to restart from current sbt project.") 29 | } 30 | 31 | test("Validate the proper restarting of a particular instance when multiple instances are running") { 32 | val instanceIdStop = "123456" 33 | val instanceIdKeep = "987654" 34 | val instanceIdLaunch = "111111" 35 | val serviceName = "service" 36 | val composePath = "path" 37 | 38 | val composeMock = getComposeMock(serviceName) 39 | 40 | val instanceStop = RunningInstanceInfo(instanceIdStop, serviceName, composePath, List.empty) 41 | val instanceKeep = RunningInstanceInfo(instanceIdKeep, serviceName, composePath, List.empty) 42 | val instanceLaunch = RunningInstanceInfo(instanceIdLaunch, serviceName, composePath, List.empty) 43 | 44 | mockDockerCommandCalls(composeMock) 45 | mockSystemSettings(composeMock, serviceName, Some(List(instanceStop, instanceKeep))) 46 | 47 | doReturn(instanceLaunch).when(composeMock).getRunningInstanceInfo(any[sbt.State], anyString, anyString, any[Iterable[ServiceInfo]]) 48 | composeMock.restartRunningInstance(null, Seq(instanceIdStop)) 49 | 50 | //Validate that only one instance is stopped and removed 51 | verify(composeMock, times(1)).dockerComposeStopInstance(anyString, anyString) 52 | verify(composeMock, times(1)).dockerComposeRemoveContainers(anyString, anyString) 53 | 54 | //Validate that a new instance is started 55 | verify(composeMock, times(1)).dockerComposeUp(anyString, anyString) 56 | } 57 | 58 | test("Validate the proper restarting of an instance when only one instance is running and no instance id is specified") { 59 | val instanceIdStop = "123456" 60 | val instanceIdLaunch = "111111" 61 | val serviceName = "service" 62 | val composePath = "path" 63 | 64 | val composeMock = getComposeMock(serviceName) 65 | 66 | val instanceStop = RunningInstanceInfo(instanceIdStop, serviceName, composePath, List.empty) 67 | val instanceLaunch = RunningInstanceInfo(instanceIdLaunch, serviceName, composePath, List.empty) 68 | 69 | mockDockerCommandCalls(composeMock) 70 | mockSystemSettings(composeMock, serviceName, Some(List(instanceStop))) 71 | 72 | doReturn(instanceLaunch).when(composeMock).getRunningInstanceInfo(any[sbt.State], anyString, anyString, any[Iterable[ServiceInfo]]) 73 | composeMock.restartRunningInstance(null, Seq.empty) 74 | 75 | //Validate that only one instance is stopped and removed 76 | verify(composeMock, times(1)).dockerComposeStopInstance(anyString, anyString) 77 | verify(composeMock, times(1)).dockerComposeRemoveContainers(anyString, anyString) 78 | 79 | //Validate that a new instance is started 80 | verify(composeMock, times(1)).dockerComposeUp(anyString, anyString) 81 | } 82 | 83 | test("Validate the correct type of exception thrown and error message shown when multiple instances are running and no instance id is specified") { 84 | val instanceId1 = "123456" 85 | val instanceId2 = "987654" 86 | val serviceName = "service" 87 | val composePath = "path" 88 | 89 | val composeMock = getComposeMock(serviceName) 90 | 91 | val instance1 = RunningInstanceInfo(instanceId1, serviceName, composePath, List.empty) 92 | val instance2 = RunningInstanceInfo(instanceId2, serviceName, composePath, List.empty) 93 | 94 | mockDockerCommandCalls(composeMock) 95 | mockSystemSettings(composeMock, serviceName, Some(List(instance1, instance2))) 96 | 97 | val thrown = intercept[IllegalArgumentException] { 98 | composeMock.restartInstancePrecheck(null, Seq.empty) 99 | } 100 | 101 | assert(thrown.getMessage == "More than one running instance from the current sbt project was detected. " + 102 | "Please provide an Instance Id parameter to the dockerComposeRestart command specifying which instance to stop.") 103 | } 104 | 105 | test("Validate the proper starting of a new instance when there is no running instance to restart") { 106 | val instanceIdLaunch = "111111" 107 | val serviceName = "service" 108 | val composePath = "path" 109 | 110 | val composeMock = getComposeMock(serviceName) 111 | 112 | val instanceLaunch = RunningInstanceInfo(instanceIdLaunch, serviceName, composePath, List.empty) 113 | 114 | mockDockerCommandCalls(composeMock) 115 | mockSystemSettings(composeMock, serviceName, Some(List.empty)) 116 | 117 | doReturn(instanceLaunch).when(composeMock).getRunningInstanceInfo(any[sbt.State], anyString, anyString, any[Iterable[ServiceInfo]]) 118 | composeMock.restartRunningInstance(null, Seq.empty) 119 | 120 | //Validate that a new instance is started 121 | verify(composeMock, times(1)).dockerComposeUp(anyString, anyString) 122 | } 123 | 124 | def getComposeMock(serviceName: String = "testservice"): DockerComposePluginLocal = { 125 | val composeMock = spy(new DockerComposePluginLocal) 126 | 127 | val composeFilePath = getClass.getResource("debug_port.yml").getPath 128 | doReturn(composeFilePath).when(composeMock).getSetting(composeFile)(null) 129 | doReturn(serviceName).when(composeMock).getSetting(composeServiceName)(null) 130 | doReturn(true).when(composeMock).getSetting(composeNoBuild)(null) 131 | doReturn(false).when(composeMock).getSetting(suppressColorFormatting)(null) 132 | doReturn(Vector.empty).when(composeMock).runVariablesForSubstitutionTask(null) 133 | doReturn(Map.empty).when(composeMock).getSetting(variablesForSubstitution)(null) 134 | doReturn(0).when(composeMock).dockerComposeUp(anyString, anyString) 135 | doNothing().when(composeMock).pullDockerImages(any[Seq[String]], any[Iterable[ServiceInfo]], any[Boolean]) 136 | doNothing().when(composeMock).buildDockerImageTask(null) 137 | 138 | composeMock 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /src/test/scala/InstanceStoppingSpec.scala: -------------------------------------------------------------------------------- 1 | import com.tapad.docker.{ DockerComposePluginLocal, RunningInstanceInfo } 2 | import org.mockito.Matchers._ 3 | import org.mockito.Mockito._ 4 | import org.scalatest.{ BeforeAndAfter, FunSuite, OneInstancePerTest } 5 | 6 | class InstanceStoppingSpec extends FunSuite with BeforeAndAfter with OneInstancePerTest with MockHelpers { 7 | test("Validate the proper stopping of a single instance when only one instance is running and no specific instances are passed in as arguments") { 8 | val instanceId = "instanceId" 9 | val composePath = "path" 10 | val serviceName = "service" 11 | val composeMock = spy(new DockerComposePluginLocal) 12 | val instance = RunningInstanceInfo(instanceId, serviceName, composePath, List.empty) 13 | 14 | mockDockerCommandCalls(composeMock) 15 | mockSystemSettings(composeMock, serviceName, Some(List(instance))) 16 | 17 | composeMock.stopRunningInstances(null, Seq.empty) 18 | 19 | //Validate that the instance was stopped and cleaned up 20 | verify(composeMock, times(1)).dockerComposeStopInstance(instanceId, composePath) 21 | verify(composeMock, times(1)).dockerComposeRemoveContainers(instanceId, composePath) 22 | } 23 | 24 | test("Validate the proper stopping of a multiple instances when no specific instances are passed in as arguments") { 25 | val instanceId = "instanceId" 26 | val composePath = "path" 27 | val serviceName = "service" 28 | val composeMock = spy(new DockerComposePluginLocal) 29 | val instance = RunningInstanceInfo(instanceId, serviceName, composePath, List.empty) 30 | val instance2 = RunningInstanceInfo("instanceId2", serviceName, composePath, List.empty) 31 | 32 | mockDockerCommandCalls(composeMock) 33 | mockSystemSettings(composeMock, serviceName, Some(List(instance, instance2))) 34 | 35 | composeMock.stopRunningInstances(null, Seq.empty) 36 | 37 | //Validate that the instance was stopped and cleaned up 38 | verify(composeMock, times(2)).dockerComposeStopInstance(anyString, anyString) 39 | verify(composeMock, times(2)).dockerComposeRemoveContainers(anyString, anyString) 40 | } 41 | 42 | test("Validate the proper stopping of a single instance when multiple instances are running") { 43 | val instanceIdStop = "instanceIdStop" 44 | val instanceIdKeep = "instanceIdKeep" 45 | val serviceName = "service" 46 | val composePath = "path" 47 | val composeMock = spy(new DockerComposePluginLocal) 48 | val instanceStop = RunningInstanceInfo(instanceIdStop, serviceName, composePath, List.empty) 49 | val instanceKeep = RunningInstanceInfo(instanceIdKeep, serviceName, composePath, List.empty) 50 | 51 | mockDockerCommandCalls(composeMock) 52 | mockSystemSettings(composeMock, serviceName, Some(List(instanceStop, instanceKeep))) 53 | 54 | composeMock.stopRunningInstances(null, Seq(instanceIdStop)) 55 | 56 | //Validate that only once instance was Stopped and Removed 57 | verify(composeMock, times(1)).setAttribute(any, any)(any[sbt.State]) 58 | verify(composeMock, times(1)).dockerComposeStopInstance(anyString, anyString) 59 | verify(composeMock, times(1)).dockerComposeRemoveContainers(anyString, anyString) 60 | } 61 | 62 | test("Validate that only instances from the current SBT project are stopped when no arguments are supplied to DockerComposeStop") { 63 | val composeMock = spy(new DockerComposePluginLocal) 64 | val serviceName = "matchingservice" 65 | val instance1 = RunningInstanceInfo("instanceName1", serviceName, "path", List.empty) 66 | val instance2 = RunningInstanceInfo("instanceName2", serviceName, "path", List.empty) 67 | val instance3 = RunningInstanceInfo("instanceName3", "nonSbtProjectService", "path", List.empty) 68 | 69 | mockDockerCommandCalls(composeMock) 70 | mockSystemSettings(composeMock, serviceName, Some(List(instance1, instance2, instance3))) 71 | 72 | composeMock.stopRunningInstances(null, Seq.empty) 73 | 74 | //Validate that only once instance was Stopped and Removed 75 | verify(composeMock, times(1)).setAttribute(any, any)(any[sbt.State]) 76 | verify(composeMock, times(2)).dockerComposeStopInstance(anyString, anyString) 77 | verify(composeMock, times(2)).dockerComposeRemoveContainers(anyString, anyString) 78 | } 79 | 80 | test("Validate that instances from any SBT project can be stopped when explicitly passed to DockerComposeStop") { 81 | val composeMock = spy(new DockerComposePluginLocal) 82 | val serviceName = "matchingservice" 83 | val instance1 = RunningInstanceInfo("instanceName1", serviceName, "path", List.empty) 84 | val instance2 = RunningInstanceInfo("instanceName2", serviceName, "path", List.empty) 85 | val instance3 = RunningInstanceInfo("instanceName3", "nonSbtProjectService", "path", List.empty) 86 | 87 | mockDockerCommandCalls(composeMock) 88 | mockSystemSettings(composeMock, serviceName, Some(List(instance1, instance2, instance3))) 89 | 90 | composeMock.stopRunningInstances(null, Seq("instanceName3")) 91 | 92 | //Validate that only once instance was Stopped and Removed 93 | verify(composeMock, times(1)).setAttribute(any, any)(any[sbt.State]) 94 | verify(composeMock, times(1)).dockerComposeStopInstance(anyString, anyString) 95 | verify(composeMock, times(1)).dockerComposeRemoveContainers(anyString, anyString) 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/test/scala/MockHelpers.scala: -------------------------------------------------------------------------------- 1 | import com.tapad.docker.DockerComposeKeys._ 2 | import com.tapad.docker.{ RunningInstanceInfo, DockerComposePluginLocal } 3 | import org.mockito.Matchers._ 4 | import org.mockito.Mockito._ 5 | 6 | trait MockHelpers { 7 | def mockSystemSettings(composeMock: DockerComposePluginLocal, serviceName: String, instances: Option[List[RunningInstanceInfo]]): Unit = { 8 | doReturn(null).when(composeMock).getPersistedState(null) 9 | doReturn(serviceName).when(composeMock).getSetting(composeServiceName)(null) 10 | doReturn(true).when(composeMock).getSetting(composeRemoveContainersOnShutdown)(null) 11 | doReturn(false).when(composeMock).getSetting(composeRemoveNetworkOnShutdown)(null) 12 | doReturn(false).when(composeMock).getSetting(composeRemoveTempFileOnShutdown)(null) 13 | doReturn(false).when(composeMock).getSetting(suppressColorFormatting)(null) 14 | doReturn(instances).when(composeMock).getAttribute(runningInstances)(null) 15 | doReturn(null).when(composeMock).removeAttribute(runningInstances)(null) 16 | doReturn(null).when(composeMock).setAttribute(any, any)(any[sbt.State]) 17 | doNothing().when(composeMock).saveInstanceState(null) 18 | } 19 | 20 | /** 21 | * Stubs out calls to Docker so that they don't actually call any Docker commands 22 | * @param composeMock Mock instance of the Plugin 23 | */ 24 | def mockDockerCommandCalls(composeMock: DockerComposePluginLocal): Unit = { 25 | doNothing().when(composeMock).dockerComposeRemoveContainers(anyString, anyString) 26 | doNothing().when(composeMock).dockerComposeStopInstance(anyString, anyString) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/test/scala/PluginGeneralSpec.scala: -------------------------------------------------------------------------------- 1 | import com.tapad.docker.DockerComposeKeys._ 2 | import com.tapad.docker.DockerComposePlugin._ 3 | import com.tapad.docker._ 4 | import org.mockito.Mockito._ 5 | import org.scalatest.mockito.MockitoSugar 6 | import org.scalatest.{ BeforeAndAfter, FunSuite, OneInstancePerTest } 7 | 8 | import scala._ 9 | import scala.io._ 10 | 11 | class PluginGeneralSpec extends FunSuite with BeforeAndAfter with OneInstancePerTest with MockitoSugar { 12 | 13 | test("Validate containsArg function") { 14 | val plugin = new DockerComposePluginLocal 15 | assert(!plugin.containsArg(skipPullArg, Seq(""))) 16 | assert(!plugin.containsArg(skipPullArg, Seq(skipBuildArg))) 17 | assert(plugin.containsArg(skipPullArg, Seq(skipPullArg))) 18 | assert(plugin.containsArg(skipPullArg, Seq(skipPullArg, skipBuildArg))) 19 | } 20 | 21 | test("Validate timeout when getting running instance info") { 22 | val serviceName = "service" 23 | val instanceName = "instance" 24 | val dockerMachineName = "default" 25 | val composeMock = spy(new DockerComposePluginLocal) 26 | doReturn(false).when(composeMock).getSetting(suppressColorFormatting)(null) 27 | doReturn("").when(composeMock).getDockerContainerId(instanceName, serviceName) 28 | 29 | val serviceInfo = ServiceInfo(serviceName, "image", "source", null) 30 | val thrown = intercept[IllegalStateException] { 31 | composeMock.populateServiceInfoForInstance(null, instanceName, dockerMachineName, List(serviceInfo), 0) 32 | } 33 | 34 | assert(thrown.getMessage === s"Cannot determine container Id for service: $serviceName") 35 | } 36 | 37 | test("Validate Docker container inspection populates ServiceInfo properly for various port formats") { 38 | val serviceName = "service" 39 | val instanceName = "instance" 40 | val containerId = "123456" 41 | val dockerMachineName = "default" 42 | val jsonStream = getClass.getResourceAsStream("docker_inspect.json") 43 | val dockerPortStream = getClass.getResourceAsStream("docker_port.txt") 44 | val inspectJson = Source.fromInputStream(jsonStream).mkString 45 | val portMappings = Source.fromInputStream(dockerPortStream).mkString 46 | println(portMappings) 47 | val composeMock = spy(new DockerComposePluginLocal) 48 | doReturn(containerId).when(composeMock).getDockerContainerId(instanceName, serviceName) 49 | doReturn(inspectJson).when(composeMock).getDockerContainerInfo(containerId) 50 | doReturn(portMappings).when(composeMock).getDockerPortMappings(containerId) 51 | doReturn(false).when(composeMock).isBoot2DockerEnvironment 52 | doReturn(false).when(composeMock).isDockerForMacEnvironment 53 | 54 | val port1 = PortInfo("0", "3000/tcp", isDebug = false) 55 | val port2 = PortInfo("0", "3001/udp", isDebug = false) 56 | val port3 = PortInfo("0", "3002", isDebug = false) 57 | val serviceInfo = ServiceInfo(serviceName, "image", "source", List(port1, port2, port3)) 58 | val serviceInfoUpdated = composeMock.populateServiceInfoForInstance(null, instanceName, dockerMachineName, List(serviceInfo), 60) 59 | 60 | assert(serviceInfoUpdated.size == 1) 61 | val portInfo = serviceInfoUpdated.head.ports 62 | 63 | assert(portInfo.size == 3) 64 | assert(portInfo.exists(port => port.containerPort.contains("3000") && port.hostPort == "32803")) 65 | assert(portInfo.exists(port => port.containerPort.contains("3001") && port.hostPort == "32802")) 66 | assert(portInfo.exists(port => port.containerPort.contains("3002") && port.hostPort == "32801")) 67 | } 68 | 69 | test("Validate Docker Compose 2.0 NetworkSettings are read when available") { 70 | val serviceName = "service" 71 | val instanceName = "instance" 72 | val containerId = "123456" 73 | val dockerMachineName = "default" 74 | val jsonStream = getClass.getResourceAsStream("docker_inspect2.0.json") 75 | val dockerPortStream = getClass.getResourceAsStream("docker_port.txt") 76 | val inspectJson = Source.fromInputStream(jsonStream).mkString 77 | val portMappings = Source.fromInputStream(dockerPortStream).mkString 78 | val composeMock = spy(new DockerComposePluginLocal) 79 | doReturn(containerId).when(composeMock).getDockerContainerId(instanceName, serviceName) 80 | doReturn(inspectJson).when(composeMock).getDockerContainerInfo(containerId) 81 | doReturn(portMappings).when(composeMock).getDockerPortMappings(containerId) 82 | doReturn(false).when(composeMock).isBoot2DockerEnvironment 83 | doReturn(false).when(composeMock).isDockerForMacEnvironment 84 | 85 | val port = PortInfo("0", "3002", isDebug = false) 86 | val serviceInfo = ServiceInfo(serviceName, "image", "source", List(port)) 87 | val serviceInfoUpdated = composeMock.populateServiceInfoForInstance(null, instanceName, dockerMachineName, List(serviceInfo), 60) 88 | assert(serviceInfoUpdated.head.containerHost == "172.18.0.1") 89 | } 90 | 91 | test("Validate Docker instance name generation is random") { 92 | val composeMock = spy(new DockerComposePluginLocal) 93 | val instanceName1 = "123" 94 | val instance1 = RunningInstanceInfo(instanceName1, "servicename", "path", List.empty) 95 | doReturn(Option(List(instance1))).when(composeMock).getAttribute(runningInstances)(null) 96 | val instanceName2 = composeMock.generateInstanceName(null) 97 | 98 | assert(instanceName1 != instanceName2) 99 | } 100 | } 101 | 102 | trait MockOutput extends PrintFormatting { 103 | var messages: Seq[String] = Seq() 104 | 105 | override def print(s: String) = messages = messages :+ s 106 | override def printBold(s: String, noColor: Boolean) = messages = messages :+ s 107 | } 108 | -------------------------------------------------------------------------------- /src/test/scala/PrintFormattingSpec.scala: -------------------------------------------------------------------------------- 1 | import com.tapad.docker.DockerComposeKeys._ 2 | import com.tapad.docker._ 3 | import net.liftweb.json.JValue 4 | import org.mockito.Mockito._ 5 | import org.mockito.Matchers._ 6 | import org.scalatest.{ BeforeAndAfter, FunSuite, OneInstancePerTest } 7 | import sbt.Keys._ 8 | import scala.io.Source 9 | 10 | class PrintFormattingSpec extends FunSuite with BeforeAndAfter with OneInstancePerTest { 11 | test("Validate table printing succeeds when no Ports are exposed") { 12 | val composeMock = spy(new DockerComposePluginLocal) 13 | doReturn(false).when(composeMock).getSetting(suppressColorFormatting)(null) 14 | 15 | val service = ServiceInfo("service", "image", "source", List.empty) 16 | val instance = RunningInstanceInfo("instance", "service", "composePath", List(service)) 17 | composeMock.printMappedPortInformation(null, instance, Version(1, 1, 11)) 18 | } 19 | 20 | test("Validate table printing succeeds when Ports are exposed") { 21 | val composeMock = spy(new DockerComposePluginLocal) 22 | doReturn(false).when(composeMock).getSetting(suppressColorFormatting)(null) 23 | 24 | val port = PortInfo("host", "container", false) 25 | val service = ServiceInfo("service", "image", "source", List(port)) 26 | val instance = RunningInstanceInfo("instance", "service", "composePath", List(service)) 27 | composeMock.printMappedPortInformation(null, instance, Version(1, 1, 11)) 28 | } 29 | 30 | test("Validate the table output shows '' when no Ports are exposed") { 31 | val (composeMock, composeFilePath) = getComposeMock("no_exposed_ports.yml") 32 | val composeYaml = composeMock.readComposeFile(composeFilePath) 33 | val serviceInfo = composeMock.processCustomTags(null, Seq.empty, composeYaml) 34 | doReturn("").when(composeMock).getDockerPortMappings("containerId01") 35 | val serviceInfoUpdated = composeMock.populateServiceInfoForInstance(null, "123456", "default", serviceInfo, 1000) 36 | 37 | val tableOutput = composeMock.getTableOutputList(serviceInfoUpdated) 38 | 39 | assert(tableOutput.toList.exists(out => 40 | out.hostWithPort.contains("none") 41 | && out.containerPort == "")) 42 | } 43 | 44 | test("Validate the table output displays protocol names for non-tcp protocols") { 45 | val (composeMock, composeFilePath) = getComposeMock("debug_port.yml") 46 | val dockerPortStream = getClass.getResourceAsStream("docker_port.txt") 47 | val portMappings = Source.fromInputStream(dockerPortStream).mkString 48 | doReturn(portMappings).when(composeMock).getDockerPortMappings("containerId01") 49 | val composeYaml = composeMock.readComposeFile(composeFilePath) 50 | val serviceInfo = composeMock.processCustomTags(null, Seq.empty, composeYaml) 51 | val serviceInfoUpdated = composeMock.populateServiceInfoForInstance(null, "123456", "default", serviceInfo, 1000) 52 | 53 | val tableOutput = composeMock.getTableOutputList(serviceInfoUpdated) 54 | val expectedContainerPorts = List("3000", "3001/udp", "3002") 55 | assert(tableOutput.toList.map(_.containerPort) == expectedContainerPorts) 56 | } 57 | 58 | test("Validate the table output is sorted by service name, then by isDebug, and last by container ports, all ascending") { 59 | // Set up Compose Mock with Port Mappings 60 | val (composeMock, composeFilePath) = getComposeMock( 61 | "sort.yml", 62 | serviceNames = List("testserviceB", "testserviceA"), 63 | containerIds = List("containerId02", "containerId01") 64 | ) 65 | val container1PortMappings = 66 | """5005/tcp -> 0.0.0.0:32803 67 | |2003/tcp -> 0.0.0.0:32804 68 | |12345/tcp -> 0.0.0.0:32805""".stripMargin 69 | val container2PortMappings = 70 | """5005/tcp -> 0.0.0.0:32806 71 | |80/tcp -> 0.0.0.0:32807 72 | |10000/tcp -> 0.0.0.0:32808 73 | |8000/udp -> 0.0.0.0:32809""".stripMargin 74 | doReturn(container1PortMappings).when(composeMock).getDockerPortMappings("containerId01") 75 | doReturn(container2PortMappings).when(composeMock).getDockerPortMappings("containerId02") 76 | 77 | // Get a collection of ServiceInfo from docker-compose file (sort.xml) 78 | val composeYaml = composeMock.readComposeFile(composeFilePath) 79 | val serviceInfo = composeMock.processCustomTags(null, Seq.empty, composeYaml) 80 | val serviceInfoUpdated = composeMock.populateServiceInfoForInstance(null, "123456", "default", serviceInfo, 1000) 81 | 82 | // Sort the Table Output Rows 83 | val tableOutput = composeMock.getTableOutputList(serviceInfoUpdated) 84 | val tableOutputSorted = tableOutput.toList.sorted 85 | 86 | // Extract the relevant columns (service name, container port, isDebug) and compare against hard-coded expectations. 87 | val expPartialTable = List( 88 | ("testserviceA", "2003", false), 89 | ("testserviceA", "12345", false), 90 | ("testserviceA", "5005", true), 91 | ("testserviceB", "80", false), 92 | ("testserviceB", "8000/udp", false), 93 | ("testserviceB", "10000", false), 94 | ("testserviceB", "5005", true) 95 | ) 96 | val actPartialTable = tableOutputSorted.map(row => (row.serviceName, row.containerPort, row.isDebug)) 97 | assert(actPartialTable == expPartialTable) 98 | } 99 | 100 | def getComposeMock( 101 | composeFileName: String, 102 | serviceNames: List[String] = List("testservice"), 103 | versionNumber: String = "1.0.0", 104 | instanceName: String = "123456", 105 | containerIds: List[String] = List("containerId01"), 106 | dockerMachineName: String = "default", 107 | containerHost: String = "192.168.99.10", 108 | noBuild: Boolean = false 109 | ): (DockerComposePluginLocal, String) = { 110 | 111 | require(serviceNames.length == containerIds.length) 112 | 113 | val composeMock = spy(new DockerComposePluginLocal) 114 | val composeFilePath = getClass.getResource(composeFileName).getPath 115 | 116 | doReturn(composeFilePath).when(composeMock).getSetting(composeFile)(null) 117 | doReturn(serviceNames.head).when(composeMock).getSetting(composeServiceName)(null) 118 | doReturn(versionNumber).when(composeMock).getSetting(version)(null) 119 | doReturn(noBuild).when(composeMock).getSetting(composeNoBuild)(null) 120 | doReturn(containerHost).when(composeMock).getContainerHost(any[String], any[String], any[JValue]) 121 | doReturn("1.0.0").when(composeMock).getComposeServiceVersion(null) 122 | 123 | serviceNames.zip(containerIds).foreach { 124 | case (serviceName, containerId) => 125 | doReturn(containerId).when(composeMock).getDockerContainerId(instanceName, serviceName) 126 | doReturn("").when(composeMock).getDockerContainerInfo(containerId) 127 | } 128 | 129 | (composeMock, composeFilePath) 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /src/test/scala/TagProcessingSpec.scala: -------------------------------------------------------------------------------- 1 | import com.tapad.docker.DockerComposePlugin._ 2 | import org.scalatest.{ BeforeAndAfter, FunSuite, OneInstancePerTest } 3 | 4 | class TagProcessingSpec extends FunSuite with BeforeAndAfter with OneInstancePerTest { 5 | 6 | val imageNoTag = "testImage" 7 | val imageLatestTag = "testImage:latest" 8 | val imageWithTag = "testImage:tag" 9 | val imagePrivateRegistryNoTag = "registry/testImage" 10 | val imagePrivateRegistryWithLatest = "registry/testImage:latest" 11 | val imagePrivateRegistryWithTag = "registry/testImage:tag" 12 | val imagePrivateRegistryWithOrgNoTag = "registry/org/testImage" 13 | val imagePrivateRegistryWithOrgWithTag = "registry/org/testImage:tag" 14 | val imageCustomTag = "testImage" 15 | val imageTagAndCustomTag = "testImage:latest" 16 | 17 | // Boundary 18 | val badImageWithColon = "testImage:" 19 | val badImageWithMultipleColon = "testImage:fooImage:latest" 20 | val badImageWithOnlyColon = ":::::::" 21 | 22 | test("Validate various image tag formats are properly replaced") { 23 | val replacementTag = "replaceTag" 24 | assert(replaceDefinedVersionTag(imageNoTag, replacementTag) == imageNoTag) 25 | 26 | assert(replaceDefinedVersionTag(imageLatestTag, replacementTag) == imageLatestTag) 27 | 28 | assert(replaceDefinedVersionTag(imageWithTag, replacementTag) == s"testImage:$replacementTag") 29 | 30 | assert(replaceDefinedVersionTag(imagePrivateRegistryNoTag, replacementTag) == imagePrivateRegistryNoTag) 31 | 32 | assert(replaceDefinedVersionTag(imagePrivateRegistryWithLatest, replacementTag) == imagePrivateRegistryWithLatest) 33 | 34 | assert(replaceDefinedVersionTag(imagePrivateRegistryWithTag, replacementTag) == s"registry/testImage:$replacementTag") 35 | } 36 | 37 | test("Validate image tag retrieval from various formats") { 38 | assert(getTagFromImage(imageNoTag) == "latest") 39 | 40 | assert(getTagFromImage(imageLatestTag) == "latest") 41 | 42 | assert(getTagFromImage(imageWithTag) == "tag") 43 | 44 | assert(getTagFromImage(imagePrivateRegistryNoTag) == "latest") 45 | 46 | assert(getTagFromImage(imagePrivateRegistryWithLatest) == "latest") 47 | 48 | assert(getTagFromImage(imagePrivateRegistryWithTag) == "tag") 49 | } 50 | 51 | test("Validate custom tags get removed") { 52 | assert(processImageTag(null, null, imageCustomTag) == "testImage") 53 | assert(processImageTag(null, null, imageTagAndCustomTag) == "testImage:latest") 54 | } 55 | 56 | test("Validate the removal of a tag from various image formats") { 57 | assert(getImageNameOnly(imageNoTag) == imageNoTag) 58 | assert(getImageNameOnly(imageLatestTag) == "testImage") 59 | assert(getImageNameOnly(imagePrivateRegistryNoTag) == "testImage") 60 | assert(getImageNameOnly(imagePrivateRegistryWithLatest) == "testImage") 61 | assert(getImageNameOnly(imagePrivateRegistryWithTag) == "testImage") 62 | assert(getImageNameOnly(imagePrivateRegistryWithOrgWithTag) == "testImage") 63 | assert(getImageNameOnly(imagePrivateRegistryWithOrgWithTag, removeOrganization = false) == "org/testImage") 64 | } 65 | 66 | test("Validate getting image name with no tag") { 67 | assert(getImageNoTag("") == "") 68 | assert(getImageNoTag(imageNoTag) == imageNoTag) 69 | assert(getImageNoTag(imageLatestTag) == imageNoTag) 70 | assert(getImageNoTag(imagePrivateRegistryNoTag) == imagePrivateRegistryNoTag) 71 | assert(getImageNoTag(imagePrivateRegistryWithLatest) == imagePrivateRegistryNoTag) 72 | assert(getImageNoTag(imagePrivateRegistryWithTag) == imagePrivateRegistryNoTag) 73 | assert(getImageNoTag(imagePrivateRegistryWithOrgWithTag) == imagePrivateRegistryWithOrgNoTag) 74 | assert(getImageNoTag(badImageWithColon) == imageNoTag) 75 | assert(getImageNoTag(badImageWithMultipleColon) == badImageWithMultipleColon.split(":").dropRight(1).mkString(":")) 76 | assert(getImageNoTag(badImageWithOnlyColon) == badImageWithOnlyColon.dropRight(1)) 77 | } 78 | } -------------------------------------------------------------------------------- /src/test/scala/VersionSpec.scala: -------------------------------------------------------------------------------- 1 | import com.tapad.docker.Version 2 | import org.scalatest.{ BeforeAndAfter, FunSuite, OneInstancePerTest } 3 | 4 | class VersionSpec extends FunSuite with BeforeAndAfter with OneInstancePerTest with MockHelpers { 5 | test("Validate version information is parsed correctly") { 6 | assert(Version.parseVersion("1.0.0") == Version(1, 0, 0)) 7 | assert(Version.parseVersion("11.1.1") == Version(11, 1, 1)) 8 | assert(Version.parseVersion("1.0.0-SNAPSHOT") == Version(1, 0, 0)) 9 | assert(Version.parseVersion("1.2.3") == Version(1, 2, 3)) 10 | assert(Version.parseVersion("1.2.3-rc3") == Version(1, 2, 3)) 11 | assert(Version.parseVersion("1.2.3rc3") == Version(1, 2, 3)) 12 | } 13 | 14 | test("Validate invalid version information reports an exception") { 15 | intercept[RuntimeException] { 16 | Version.parseVersion("") 17 | } 18 | 19 | intercept[RuntimeException] { 20 | Version.parseVersion("1.0") 21 | } 22 | 23 | intercept[RuntimeException] { 24 | Version.parseVersion("-1.0") 25 | } 26 | 27 | intercept[RuntimeException] { 28 | Version.parseVersion("version") 29 | } 30 | } 31 | } -------------------------------------------------------------------------------- /version.sbt: -------------------------------------------------------------------------------- 1 | version in ThisBuild := "1.0.36-SNAPSHOT" 2 | --------------------------------------------------------------------------------