Skip to content

Commit

Permalink
feat: docker-compose v2 support (#339)
Browse files Browse the repository at this point in the history
* Update configuration fixtures to version 2

* Cater for ps on non-existing containers

docker-compose v2 returns an exit code of 1 when running ps on a
non-existent container, when v1 returned 0 and an empty list.

* Remove failing container test

docker-compose v2 starts containers that depend on failed containers.
This may be considered a bug or a feature.  Either way, the only way to
recover is to bring all containers down.

* Cope with missing version in configuration

docker-compose config doesn't always include the version number from
the original configuration, so this can't be reliably used to know
whether the services are under the services key or directly under the
root.

* Cope with new container naming convention

docker-compose v2 now names containers in the form:

${container_id}_${name}-\d+

Previously, it used an underscore after the name.

* More graceful handling of missing services

docker-compose ps returns an exit code of 1 when a named service doesn't
exist.  Rather than trying to work out why the exit code is 1, get the
service list first to see whether it's worth running ps with a service
name.

* Explain reasoning behind matching logic

* More thoroughly test output capturing

Test that output from containers is captured by docker-compose.  v2
strangely strips newlines in certain circumstances.
  • Loading branch information
simondwilliams authored Jan 26, 2022
1 parent c9e3ccc commit 56bd6ab
Show file tree
Hide file tree
Showing 9 changed files with 118 additions and 169 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ class ComposeConfigParser
{
Map<String, Object> parsed = new Yaml().load(composeConfigOutput)
// if there is 'version' on top-level then information about services is in 'services' sub-tree
Map<String, Object> services = (parsed.version ? parsed.services : parsed)
Map<String, Object> services = (parsed.services ? parsed.services : parsed)
Map<String, Set<String>> declaredServiceDependencies = services.collectEntries { [(it.key): getDirectServiceDependencies(it.value)] }
services.keySet().collectEntries { [(it): calculateDependenciesFromGraph(it, declaredServiceDependencies)] }
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,14 @@ class ComposeExecutor {
}

Iterable<String> getContainerIds(String serviceName) {
execute('ps', '-q', serviceName).readLines()
// `docker-compose ps -q serviceName` returns an exit code of 1 when the service
// doesn't exist. To guard against this, check the service list first.
def services = execute('ps', '--services').readLines()
if (services.contains(serviceName)) {
return execute('ps', '-q', serviceName).readLines()
}

return []
}

void captureContainersOutput(Closure<Void> logMethod, String... services) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -291,20 +291,22 @@ abstract class ComposeSettings {
}

protected Map<String, Object> createEnvironmentVariables(String variableName, ContainerInfo ci) {
def serviceName = variableName.replaceAll('-', '_')
Map<String, Object> environmentVariables = [:]
environmentVariables.put("${variableName}_HOST".toString(), ci.host)
environmentVariables.put("${variableName}_CONTAINER_HOSTNAME".toString(), ci.containerHostname)
ci.tcpPorts.each { environmentVariables.put("${variableName}_TCP_${it.key}".toString(), it.value) }
ci.udpPorts.each { environmentVariables.put("${variableName}_UDP_${it.key}".toString(), it.value) }
environmentVariables.put("${serviceName}_HOST".toString(), ci.host)
environmentVariables.put("${serviceName}_CONTAINER_HOSTNAME".toString(), ci.containerHostname)
ci.tcpPorts.each { environmentVariables.put("${serviceName}_TCP_${it.key}".toString(), it.value) }
ci.udpPorts.each { environmentVariables.put("${serviceName}_UDP_${it.key}".toString(), it.value) }
environmentVariables
}

protected Map<String, Object> createSystemProperties(String variableName, ContainerInfo ci) {
def serviceName = variableName.replaceAll('-', '_')
Map<String, Object> systemProperties = [:]
systemProperties.put("${variableName}.host".toString(), ci.host)
systemProperties.put("${variableName}.containerHostname".toString(), ci.containerHostname)
ci.tcpPorts.each { systemProperties.put("${variableName}.tcp.${it.key}".toString(), it.value) }
ci.udpPorts.each { systemProperties.put("${variableName}.udp.${it.key}".toString(), it.value) }
systemProperties.put("${serviceName}.host".toString(), ci.host)
systemProperties.put("${serviceName}.containerHostname".toString(), ci.containerHostname)
ci.tcpPorts.each { systemProperties.put("${serviceName}.tcp.${it.key}".toString(), it.value) }
ci.udpPorts.each { systemProperties.put("${serviceName}.udp.${it.key}".toString(), it.value) }
systemProperties
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,10 @@ class ComposeUp extends DefaultTask {
logger.info("Will use $host as host of service $serviceName")
def tcpPorts = settings.dockerExecutor.getTcpPortsMapping(serviceName, inspection, host)
def udpPorts = settings.dockerExecutor.getUdpPortsMapping(serviceName, inspection, host)
String instanceName = inspection.Name.find(/${serviceName}_\d+/) ?: inspection.Name - '/'
// docker-compose v1 uses an underscore as a separator. v2 uses a hyphen.
String instanceName = inspection.Name.find(/${serviceName}_\d+$/) ?:
inspection.Name.find(/${serviceName}-\d+$/) ?:
inspection.Name - '/'
new ContainerInfo(
instanceName: instanceName,
serviceHost: host,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,13 @@ import spock.lang.Specification
class CaptureOutputTest extends Specification {

private String composeFileContent = '''
web:
image: nginx:stable
command: bash -c "echo -e 'heres some output\\nand some more' && sleep 5 && nginx -g 'daemon off;'"
ports:
- 80
version: '2'
services:
web:
image: nginx:stable
command: bash -c "echo -e 'here is some output' && echo -e 'and some more' && sleep 5 && nginx -g 'daemon off;'"
ports:
- 80
'''

def "captures container output to stdout"() {
Expand All @@ -34,7 +36,9 @@ class CaptureOutputTest extends Specification {
f.project.tasks.composeUp.up()
then:
noExceptionThrown()
stdout.toString().contains("web_1 | heres some output\nweb_1 | and some more")
stdout.toString().contains("web_1 | here is some output\nweb_1 | and some more") ||
(stdout.toString().contains("web-1 | here is some output") &&
stdout.toString().contains("web-1 | and some more"))
cleanup:
f.project.tasks.composeDown.down()
f.close()
Expand All @@ -48,7 +52,9 @@ class CaptureOutputTest extends Specification {
f.project.tasks.composeUp.up()
then:
noExceptionThrown()
logFile.text.contains("web_1 | heres some output\nweb_1 | and some more")
logFile.text.contains("web_1 | here is some output\nweb_1 | and some more") ||
(logFile.text.contains("web-1 | here is some output") &&
logFile.text.contains("web-1 | and some more"))
cleanup:
f.project.tasks.composeDown.down()
f.close()
Expand All @@ -62,7 +68,9 @@ class CaptureOutputTest extends Specification {
f.project.tasks.composeUp.up()
then:
noExceptionThrown()
logFile.text.contains("web_1 | heres some output\nweb_1 | and some more")
logFile.text.contains("web_1 | here is some output\nweb_1 | and some more") ||
(logFile.text.contains("web-1 | here is some output") &&
logFile.text.contains("web-1 | and some more"))
cleanup:
f.project.tasks.composeDown.down()
f.close()
Expand All @@ -77,7 +85,9 @@ class CaptureOutputTest extends Specification {
then:
noExceptionThrown()
def logFile = logDir.toPath().resolve('web.log').toFile()
logFile.text.contains("web_1 | heres some output\nweb_1 | and some more")
logFile.text.contains("web_1 | here is some output\nweb_1 | and some more") ||
(logFile.text.contains("web-1 | here is some output") &&
logFile.text.contains("web-1 | and some more"))
cleanup:
f.project.tasks.composeDown.down()
f.close()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,32 +1,10 @@
package com.avast.gradle.dockercompose

import org.gradle.api.tasks.testing.Test
import spock.lang.Shared
import spock.lang.Specification
import spock.lang.Unroll

class ComposeExecutorTest extends Specification {
@Shared
def composeV1_webMasterWithDeps =
'''
web0:
image: nginx:stable
ports:
- 80
web1:
image: nginx:stable
ports:
- 80
links:
- web0
webMaster:
image: nginx:stable
ports:
- 80
links:
- web1
'''

@Shared
def composeV2_webMasterWithDeps =
'''
Expand All @@ -40,38 +18,23 @@ class ComposeExecutorTest extends Specification {
image: nginx:stable
ports:
- 80
links:
depends_on:
- web0
webMaster:
image: nginx:stable
ports:
- 80
links:
- web1
'''

@Shared
def composeWithFailingContainer = '''
version: '3.9'
services:
fail:
image: nginx:stable
command: bash -c "echo not so stable && exit 1"
double_fail:
image: hello-world
depends_on:
fail:
condition: service_completed_successfully
- web1
'''

@Unroll
def "getServiceNames calculates service names correctly when includeDependencies is #includeDependencies" () {
def f = Fixture.custom(composeFile)
def f = Fixture.custom(composeV2_webMasterWithDeps)
f.project.plugins.apply 'java'
f.project.dockerCompose.includeDependencies = includeDependencies
f.project.dockerCompose.startedServices = ['webMaster']
f.project.plugins.apply 'docker-compose'
Test test = f.project.tasks.test as Test

when:
def configuredServices = f.project.dockerCompose.composeExecutor.getServiceNames()
Expand All @@ -84,35 +47,8 @@ class ComposeExecutorTest extends Specification {

where:
// test it for both compose file version 1 and 2
includeDependencies | expectedServices | composeFile
true | ["webMaster", "web0", "web1"] | composeV1_webMasterWithDeps
false | ["webMaster"] | composeV1_webMasterWithDeps
true | ["webMaster", "web0", "web1"] | composeV2_webMasterWithDeps
false | ["webMaster"] | composeV2_webMasterWithDeps
}

def "If composeUp fails, containers should be deleted depending on retainContainersOnStartupFailure setting"() {
setup:
def f = Fixture.custom(composeWithFailingContainer)
f.project.plugins.apply 'java'
f.project.dockerCompose.startedServices = ['fail', 'double_fail']
f.project.dockerCompose.retainContainersOnStartupFailure = retain
f.project.dockerCompose
f.project.plugins.apply 'docker-compose'

when:
f.project.tasks.composeUp.up()

then:
thrown(RuntimeException)
assert f.project.dockerCompose.composeExecutor.getContainerIds('fail').size() == (retain ? 1 : 0)
assert f.project.dockerCompose.composeExecutor.getContainerIds('double_fail').isEmpty()

cleanup:
f.project.tasks.composeDownForced.down()
f.close()

where:
retain << [true, false]
includeDependencies | expectedServices
true | ["webMaster", "web0", "web1"]
false | ["webMaster"]
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,19 @@ class CustomComposeFilesTest extends Specification {
def "can specify compose files to use"() {
def projectDir = File.createTempDir("gradle", "projectDir")
new File(projectDir, 'original.yml') << '''
web:
image: nginx:stable
ports:
- 80
version: '2'
services:
web:
image: nginx:stable
ports:
- 80
'''
new File(projectDir, 'override.yml') << '''
web:
ports:
- 8080
version: '2'
services:
web:
ports:
- 8080
'''
def project = ProjectBuilder.builder().withProjectDir(projectDir).build()
project.plugins.apply 'docker-compose'
Expand Down Expand Up @@ -45,17 +49,21 @@ class CustomComposeFilesTest extends Specification {
def "docker-compose.override.yml file honoured when no files specified"() {
def projectDir = File.createTempDir("gradle", "projectDir")
new File(projectDir, 'docker-compose.yml') << '''
web:
image: nginx:stable
version: '2'
services:
web:
image: nginx:stable
'''
new File(projectDir, 'docker-compose.override.yml') << '''
web:
ports:
- 80
devweb:
image: nginx:stable
ports:
- 80
version: '2'
services:
web:
ports:
- 80
devweb:
image: nginx:stable
ports:
- 80
'''
def project = ProjectBuilder.builder().withProjectDir(projectDir).build()
project.plugins.apply 'docker-compose'
Expand All @@ -80,22 +88,28 @@ class CustomComposeFilesTest extends Specification {
def "docker-compose.override.yml file ignored when files are specified"() {
def projectDir = File.createTempDir("gradle", "projectDir")
new File(projectDir, 'docker-compose.yml') << '''
web:
image: nginx:stable
version: '2'
services:
web:
image: nginx:stable
'''
new File(projectDir, 'docker-compose.override.yml') << '''
web:
ports:
- 80
devweb:
image: nginx:stable
ports:
- 80
version: '2'
services:
web:
ports:
- 80
devweb:
image: nginx:stable
ports:
- 80
'''
new File(projectDir, 'docker-compose.prod.yml') << '''
web:
ports:
- 8080
version: '2'
services:
web:
ports:
- 8080
'''
def project = ProjectBuilder.builder().withProjectDir(projectDir).build()
project.plugins.apply 'docker-compose'
Expand Down
Loading

0 comments on commit 56bd6ab

Please sign in to comment.