com.avast.gradle.dockercompose.ComposeExecutor.groovy Maven / Gradle / Ivy
Go to download
Show more of this group Show more artifacts with this name
Show all versions of gradle-docker-compose-plugin Show documentation
Show all versions of gradle-docker-compose-plugin Show documentation
Simplifies usage of Docker Compose for integration testing in Gradle environment.
The newest version!
package com.avast.gradle.dockercompose
import org.gradle.api.Project
import org.gradle.api.file.DirectoryProperty
import org.gradle.api.internal.file.FileOperations
import org.gradle.api.logging.Logger
import org.gradle.api.logging.Logging
import org.gradle.api.provider.ListProperty
import org.gradle.api.provider.MapProperty
import org.gradle.api.provider.Property
import org.gradle.api.provider.Provider
import org.gradle.api.services.BuildService
import org.gradle.api.services.BuildServiceParameters
import org.gradle.internal.UncheckedException
import org.gradle.process.ExecOperations
import org.gradle.process.ExecSpec
import org.yaml.snakeyaml.Yaml
import javax.inject.Inject
import java.lang.ref.WeakReference
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.Executors
import com.avast.gradle.dockercompose.util.VersionNumber
abstract class ComposeExecutor implements BuildService, AutoCloseable {
static interface Parameters extends BuildServiceParameters {
abstract DirectoryProperty getProjectDirectory()
abstract ListProperty getStartedServices()
abstract ListProperty getUseComposeFiles()
abstract Property getIncludeDependencies()
abstract DirectoryProperty getDockerComposeWorkingDirectory()
abstract MapProperty getEnvironment()
abstract Property getExecutable()
abstract Property getDockerExecutable()
abstract Property getUseDockerComposeV2()
abstract Property getProjectName()
abstract ListProperty getComposeAdditionalArgs()
abstract Property getRemoveOrphans()
abstract MapProperty getScale()
}
static Provider getInstance(Project project, ComposeSettings settings) {
String serviceId = "${ComposeExecutor.class.canonicalName} $project.path ${settings.hashCode()}"
return project.gradle.sharedServices.registerIfAbsent(serviceId, ComposeExecutor) {
it.parameters.projectDirectory.set(project.layout.projectDirectory)
it.parameters.startedServices.set(settings.startedServices)
it.parameters.useComposeFiles.set(settings.useComposeFiles)
it.parameters.includeDependencies.set(settings.includeDependencies)
it.parameters.dockerComposeWorkingDirectory.set(settings.dockerComposeWorkingDirectory)
it.parameters.environment.set(settings.environment)
it.parameters.executable.set(settings.executable)
it.parameters.dockerExecutable.set(settings.dockerExecutable)
it.parameters.useDockerComposeV2.set(settings.useDockerComposeV2)
it.parameters.projectName.set(settings.projectName)
it.parameters.composeAdditionalArgs.set(settings.composeAdditionalArgs)
it.parameters.removeOrphans.set(settings.removeOrphans)
it.parameters.scale.set(settings.scale)
}
}
@Inject
abstract ExecOperations getExec()
@Inject
abstract FileOperations getFileOps()
private static final Logger logger = Logging.getLogger(ComposeExecutor.class);
void executeWithCustomOutputWithExitValue(OutputStream os, String... args) {
executeWithCustomOutput(os, false, true, true, args)
}
void executeWithCustomOutputNoExitValue(OutputStream os, String... args) {
executeWithCustomOutput(os, true, true, true, args)
}
void executeWithCustomOutput(OutputStream os, Boolean ignoreExitValue, Boolean noAnsi, Boolean captureStderr, String... args) {
def er = exec.exec { ExecSpec e ->
if (parameters.dockerComposeWorkingDirectory.isPresent()) {
e.setWorkingDir(parameters.dockerComposeWorkingDirectory.get().asFile)
} else {
e.setWorkingDir(parameters.projectDirectory)
}
e.environment = System.getenv() + parameters.environment.get()
def finalArgs = []
finalArgs.addAll(getDockerComposeBaseCommand())
finalArgs.addAll(parameters.useComposeFiles.get().collectMany { ['-f', it].asCollection() })
finalArgs.addAll(parameters.composeAdditionalArgs.get())
if (noAnsi) {
if (version >= VersionNumber.parse('1.28.0')) {
finalArgs.addAll(['--ansi', 'never'])
} else if (version >= VersionNumber.parse('1.16.0')) {
finalArgs.add('--no-ansi')
}
}
String pn = parameters.projectName.getOrNull()
if (pn) {
finalArgs.addAll(['-p', pn])
}
finalArgs.addAll(args)
e.commandLine finalArgs
if (os != null) {
e.standardOutput = os
if (captureStderr) {
e.errorOutput = os
}
}
e.ignoreExitValue = true
}
if (!ignoreExitValue && er.exitValue != 0) {
def stdout = os != null ? os.toString().trim() : "N/A"
throw new RuntimeException("Exit-code ${er.exitValue} when calling ${parameters.executable.get()}, stdout: $stdout")
}
}
String execute(String... args) {
new ByteArrayOutputStream().withStream { os ->
executeWithCustomOutput(os, false, true, false, args)
os.toString().trim()
}
}
private String executeWithAnsi(String... args) {
new ByteArrayOutputStream().withStream { os ->
executeWithCustomOutput(os, false, false, false, args)
os.toString().trim()
}
}
private VersionNumber cachedVersion
VersionNumber getVersion() {
if (cachedVersion) return cachedVersion
String rawVersion = executeWithAnsi('version', '--short')
cachedVersion = VersionNumber.parse(rawVersion.startsWith('v') ? rawVersion.substring(1) : rawVersion)
}
Map> getContainerIds(List serviceNames) {
// `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()
def result = [:]
for (String serviceName: serviceNames) {
if (services.contains(serviceName)) {
def containerIds = execute('ps', '-q', serviceName).readLines()
result[serviceName] = containerIds
} else {
result[serviceName] = []
}
}
return result
}
private Set> threadsToInterruptOnClose = ConcurrentHashMap.newKeySet()
void captureContainersOutput(Closure logMethod, String... services) {
// execute daemon thread that executes `docker-compose logs -f --no-color`
// the -f arguments means `follow` and so this command ends when docker-compose finishes
def t = Executors.defaultThreadFactory().newThread(new Runnable() {
@Override
void run() {
def os = new OutputStream() {
ArrayList buffer = new ArrayList()
@Override
void write(int b) throws IOException {
// store bytes into buffer until end-of-line character is detected
if (b == 10 || b == 13) {
if (buffer.size() > 0) {
// convert the byte buffer to characters and print these characters
def toPrint = buffer.collect { it as byte }.toArray() as byte[]
logMethod(new String(toPrint))
buffer.clear()
}
} else {
buffer.add(b as Byte)
}
}
}
try {
executeWithCustomOutput(os, true, true, true, 'logs', '-f', '--no-color', *services)
} catch (InterruptedException e) {
logger.trace("Thread capturing container output has been interrupted, this is not an error", e)
} catch (UncheckedException ue) {
if (ue.cause instanceof InterruptedException) {
// Gradle < 5.0 incorrectly wrapped InterruptedException to UncheckedException
logger.trace("Thread capturing container output has been interrupted, this is not an error", ue)
} else {
throw ue
}
} finally {
os.close()
}
}
})
t.daemon = true
t.start()
threadsToInterruptOnClose.add(new WeakReference(t))
}
@Override
void close() throws Exception {
threadsToInterruptOnClose.forEach {threadRef ->
def thread = threadRef.get()
if (thread != null) {
thread.interrupt()
}
}
}
Iterable getServiceNames() {
if (!parameters.startedServices.get().empty) {
if(parameters.includeDependencies.get())
{
def dependentServices = getDependentServices(parameters.startedServices.get()).toList()
[*parameters.startedServices.get(), *dependentServices].unique()
}
else
{
parameters.startedServices.get()
}
} else if (version >= VersionNumber.parse('1.6.0')) {
execute('config', '--services').readLines()
} else {
def composeFiles = parameters.useComposeFiles.get().empty ? getStandardComposeFiles() : getCustomComposeFiles()
composeFiles.collectMany { composeFile ->
def compose = (Map) (new Yaml().load(fileOps.file(composeFile).text))
// if there is 'version' on top-level then information about services is in 'services' sub-tree
compose.containsKey('version') ? ((Map) compose.get('services')).keySet() : compose.keySet()
}.unique()
}
}
/**
* Calculates dependent services for the given set of services. The full dependency graph will be calculated, such that transitive dependencies will be returned.
* @param serviceNames the name of services to calculate dependencies for
* @return the set of services that are dependencies of the given services
*/
Iterable getDependentServices(Iterable serviceNames) {
def configOutput = execute('config')
def dependencyGraph = ComposeConfigParser.findServiceDependencies(configOutput)
serviceNames.collectMany { dependencyGraph.getOrDefault(it, [].toSet()) }
}
Iterable getStandardComposeFiles() {
File searchDirectory = fileOps.file(parameters.dockerComposeWorkingDirectory) ?: parameters.projectDirectory.getAsFile()
def res = []
def f = findInParentDirectories('docker-compose.yml', searchDirectory)
if (f != null) res.add(f)
f = findInParentDirectories('docker-compose.override.yml', searchDirectory)
if (f != null) res.add(f)
res
}
Iterable getCustomComposeFiles() {
parameters.useComposeFiles.get().collect {
def f = fileOps.file(it)
if (!f.exists()) {
throw new IllegalArgumentException("Custom Docker Compose file not found: $f")
}
f
}
}
File findInParentDirectories(String filename, File directory) {
if ((directory) == null) return null
def f = new File(directory, filename)
f.exists() ? f : findInParentDirectories(filename, directory.parentFile)
}
boolean shouldRemoveOrphans() {
version >= VersionNumber.parse('1.7.0') && parameters.removeOrphans.get()
}
boolean isScaleSupported() {
def v = version
if (v < VersionNumber.parse('1.13.0') && parameters.scale) {
throw new UnsupportedOperationException("Docker Compose version $v doesn't support --scale option")
}
!parameters.scale.get().isEmpty()
}
// Determines whether to use docker-compose (V1) or docker compose (V2)
List getDockerComposeBaseCommand() {
parameters.useDockerComposeV2.get()
? [parameters.dockerExecutable.get(), "compose"]
: Arrays.asList(parameters.executable.get().split("\\s+")) // split on spaces
}
}
© 2015 - 2025 Weber Informatics LLC | Privacy Policy