diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml index 97ff743..5a70962 100644 --- a/.github/workflows/push.yml +++ b/.github/workflows/push.yml @@ -12,20 +12,20 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 steps: - - name: Checkout - uses: actions/checkout@v2 - - name: Setup Java - uses: actions/setup-java@v1 - with: - java-version: 8 - - name: Setup Gradle Cache - uses: actions/cache@v1 - with: - path: ~/.gradle/caches - key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*') }} - restore-keys: | - ${{ runner.os }}-gradle- - - name: Build Plugin - uses: eskatos/gradle-command-action@v1 - with: - arguments: build --info + - name: Checkout + uses: actions/checkout@v2 + - name: Setup Java + uses: actions/setup-java@v1 + with: + java-version: 8 + - name: Setup Gradle Cache + uses: actions/cache@v1 + with: + path: ~/.gradle/caches + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*') }} + restore-keys: | + ${{ runner.os }}-gradle- + - name: Build Plugin + uses: eskatos/gradle-command-action@v1 + with: + arguments: build --info diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 672154c..576358e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,33 +1,33 @@ name: Release on: release: - types: [published] + types: [ published ] jobs: build: name: Publish Gradle Plugin runs-on: ubuntu-latest timeout-minutes: 30 steps: - - name: Checkout - uses: actions/checkout@v2 - - name: Setup Java - uses: actions/setup-java@v1 - with: - java-version: 8 - - name: Setup Gradle Cache - uses: actions/cache@v1 - with: - path: ~/.gradle/caches - key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*') }} - restore-keys: | - ${{ runner.os }}-gradle- - - name: Set Version Environment Variable - run: | - export NEW_VERSION=$(echo "${GITHUB_REF}" | cut -d "/" -f3) - echo "NEW_VERSION=$NEW_VERSION" >> $GITHUB_ENV - - name: Publish Gradle Plugin - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - uses: eskatos/gradle-command-action@v1 - with: - arguments: build publish -Pversion=${{ env.NEW_VERSION }} --info + - name: Checkout + uses: actions/checkout@v2 + - name: Setup Java + uses: actions/setup-java@v1 + with: + java-version: 8 + - name: Setup Gradle Cache + uses: actions/cache@v1 + with: + path: ~/.gradle/caches + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*') }} + restore-keys: | + ${{ runner.os }}-gradle- + - name: Set Version Environment Variable + run: | + export NEW_VERSION=$(echo "${GITHUB_REF}" | cut -d "/" -f3) + echo "NEW_VERSION=$NEW_VERSION" >> $GITHUB_ENV + - name: Publish Gradle Plugin + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + uses: eskatos/gradle-command-action@v1 + with: + arguments: build publish -Pversion=${{ env.NEW_VERSION }} --info diff --git a/README.md b/README.md index 43ca35c..f058f24 100644 --- a/README.md +++ b/README.md @@ -6,26 +6,22 @@ - [Introduction](#introduction) - [Requirements](#requirements) - [Building](#building) - - [Build and Publish the Plugin](#build-and-publish-the-plugin) + - [Build and Publish the Plugin](#build-and-publish-the-plugin) - [Using the Plugin](#using-the-plugin) ## Introduction -This repository provides a Gradle plugin that supports building interdependent -Docker images with [Buildkit] support. +This repository provides a Gradle plugin that supports building interdependent Docker images with [Buildkit] support. -The plugin is setup such that it will automatically detect which folders should -be considered -[projects](https://docs.gradle.org/current/dsl/org.gradle.api.Project.html) and -what dependencies exist between them. The only caveat is that the projects -cannot be nested, though that use case does not really apply. +The plugin is setup such that it will automatically detect which folders should be considered +[projects](https://docs.gradle.org/current/dsl/org.gradle.api.Project.html) and what dependencies exist between them. +The only caveat is that the projects cannot be nested, though that use case does not really apply. The dependencies are resolved by parsing the Dockerfile and looking for ``FROM`` statements to determine which images are required to build it. -This means to add a new Docker image to the project you do not need to modify -the build scripts, simply add a new folder and place your Dockerfile inside of -it and it will be discovered built in the correct order relative to the other +This means to add a new Docker image to the project you do not need to modify the build scripts, simply add a new folder +and place your Dockerfile inside of it and it will be discovered built in the correct order relative to the other images. ## Requirements @@ -36,10 +32,9 @@ To build this plugin the following is required: ## Building -The build scripts rely on Gradle and should function equally well across -platforms. The only difference being the script you call to interact with gradle -(the following assumes you are executing from the **root directory** of the -project): +The build scripts rely on Gradle and should function equally well across platforms. The only difference being the script +you call to interact with gradle +(the following assumes you are executing from the **root directory** of the project): **Linux or OSX:** @@ -53,11 +48,10 @@ project): gradlew.bat ``` -For the remaining examples the **Linux or OSX** call method will be used, if -using Windows substitute the call to Gradle script. +For the remaining examples the **Linux or OSX** call method will be used, if using Windows substitute the call to Gradle +script. -Gradle is a project/task based build system to query all the available tasks use -the following command. +Gradle is a project/task based build system to query all the available tasks use the following command. ```bash ./gradlew tasks --all @@ -75,9 +69,9 @@ Tasks runnable from root project Build tasks ----------- assemble - Assembles the outputs of this project. -build - Assembles and tests this project. -buildDependents - Assembles and tests this project and all projects that depend on it. -buildNeeded - Assembles and tests this project and all projects it depends on. +build - Assembles and tasks.tests this project. +buildDependents - Assembles and tasks.tests this project and all projects that depend on it. +buildNeeded - Assembles and tasks.tests this project and all projects it depends on. classes - Assembles main classes. clean - Deletes the build directory. jar - Assembles a jar archive containing the main classes. @@ -86,13 +80,13 @@ testClasses - Assembles test classes. ... ``` -In Gradle each Project maps onto a folder in the file system path where it is -delimited by ``:`` instead of ``/`` (Unix) or ``\`` (Windows). +In Gradle each Project maps onto a folder in the file system path where it is delimited by ``:`` instead of ``/`` (Unix) +or ``\`` (Windows). The root project ``:`` can be omitted. -So if you want to run a particular task ``taskname`` that resided in the project -folder ``project/subproject`` you would specify it like so: +So if you want to run a particular task ``taskname`` that resided in the project folder ``project/subproject`` you would +specify it like so: ```bash ./gradlew :project:subproject:taskname @@ -114,8 +108,7 @@ The following will build and test the plugin. ./gradlew build ``` -The following will publish the module to Github packages, which requires you -setup a personal access token. +The following will publish the module to Github packages, which requires you setup a personal access token. ```bash export GITHUB_REPOSITORY=Islandora-Devops/isle-gradle-docker-plugin @@ -124,16 +117,14 @@ export GITHUB_TOKEN=XXXXXXXXXXXXXXXXX ./gradlew build publish ``` -Alternatively you can rely on the Github actions which will publish when a -release is made. +Alternatively you can rely on the Github actions which will publish when a release is made. > N.B. It is NOT POSSIBLE to delete/replace packages on a public repository (except *-SNAPSHOT). A new release must be made. ## Using the Plugin -To include this plugin in another project use the following snippet of Kotlin script in -your Gradle project with the `settings.gradle.kts` file that allows the plugin -source to be discoverable: +To include this plugin in another project use the following snippet of Kotlin script in your Gradle project with +the `settings.gradle.kts` file that allows the plugin source to be discoverable: ```kotlin sourceControl { diff --git a/build.gradle.kts b/build.gradle.kts index 2181000..8b5927b 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,5 +1,5 @@ import java.text.SimpleDateFormat -import java.util.Date +import java.util.* plugins { `kotlin-dsl` @@ -14,6 +14,8 @@ repositories { dependencies { implementation("com.bmuschko:gradle-docker-plugin:6.7.0") + implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.9.8") + implementation("com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.9.8") } java { @@ -48,4 +50,8 @@ publishing { } } } +} + +kotlinDslPluginOptions { + experimentalWarning.set(false) } \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index ac4fe83..1698a6e 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,3 +1,3 @@ org.gradle.parallel=true org.gradle.caching=true -version=0.0.3-SNAPSHOT \ No newline at end of file +version=0.0.4-SNAPSHOT \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index a4b4429..442d913 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-6.3-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-6.8.3-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/src/main/kotlin/IsleDocker.gradle.kts b/src/main/kotlin/IsleDocker.gradle.kts new file mode 100644 index 0000000..e51d2f6 --- /dev/null +++ b/src/main/kotlin/IsleDocker.gradle.kts @@ -0,0 +1,529 @@ +@file:Suppress("UnstableApiUsage") + +import com.github.dockerjava.api.DockerClient +import com.github.dockerjava.core.DefaultDockerClientConfig +import com.github.dockerjava.core.DockerClientBuilder +import com.github.dockerjava.httpclient5.ApacheDockerHttpClient +import org.gradle.nativeplatform.platform.internal.DefaultNativePlatform +import tasks.DockerBuild +import tasks.DockerBuilder +import utils.imageTags +import utils.isDockerProject +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.net.HttpURLConnection +import java.net.URL + +val os = DefaultNativePlatform.getCurrentOperatingSystem()!! +val arch = DefaultNativePlatform.getCurrentArchitecture()!! +val isleBuildkitGroup = "isle-buildkit" + +extensions.findByName("buildScan")?.withGroovyBuilder { + setProperty("termsOfServiceUrl", "https://gradle.com/terms-of-service") + setProperty("termsOfServiceAgree", "yes") +} + +// The repository to place the images into. +val repository by extra(properties.getOrDefault("docker.repository", "local") as String) +val isLocalRepository by extra(repository == "local") +// It's important to note that we’re using a domain containing a "." here, i.e. localhost.domain. +// If it were missing Docker would believe that localhost is a username, as in localhost/ubuntu. +// It would then try to push to the default Central Registry rather than our local repository. +val localRepository by extra("isle-buildkit.registry") + +// The build driver to use. +val buildDriver by extra(properties.getOrDefault("docker.driver", "docker") as String) +val isDockerBuild by extra(buildDriver == "docker") +val isContainerBuild by extra(buildDriver == "docker-container") + +// The mode to use when populating the registry cache. +@Suppress("unused") +val cacheToMode by extra(properties.getOrDefault("docker.cacheToMode", + if (isDockerBuild) "inline" else "max") as String) + +// Enable caching from/to repositories. +@Suppress("unused") +val cacheFromEnabled by extra((properties.getOrDefault("docker.cacheFrom", "true") as String).toBoolean()) +@Suppress("unused") +val cacheToEnabled by extra((properties.getOrDefault("docker.cacheTo", "false") as String).toBoolean()) + +// Sources to search for images to use as caches when building. +@Suppress("unused") +val cacheFromRepositories by extra( + (properties.getOrDefault("docker.cacheFromRepositories", "") as String) + .split(',') + .filter { it.isNotEmpty() } + .map { it.trim() } + .toSet() + .let { repositories -> + if (repositories.isEmpty()) { + if (cacheToEnabled) { + // Can only cache from repositories in which we have cached to. + setOf("islandora", if (isLocalRepository) localRepository else repository) + } + else { + // Always cache to/from islandora. + setOf("islandora") + } + } else repositories + } +) + +// Repositories to push cache to (empty by default). +@Suppress("unused") +val cacheToRepositories by extra( + (properties.getOrDefault("docker.cacheToRepositories", "") as String) + .split(',') + .map { it.trim() } + .filter { it.isNotEmpty() } + .toSet() + .let { repositories -> + if (repositories.isEmpty()) { + setOf(if (isLocalRepository) localRepository else repository) + } else repositories + } +) + +// Optionally disable the build cache as well as the remote cache. +@Suppress("unused") +val noBuildCache by extra((properties.getOrDefault("docker.noCache", false) as String).toBoolean()) + +// Platforms to built images to target. +@Suppress("unused") +val buildPlatforms by extra( + (properties.getOrDefault("docker.platforms", "") as String) + .split(',') + .map { it.trim() } + .filter { it.isNotEmpty() } + .toSet() +) + +// Never empty if user does not specify it will default to 'latest'. +val tags by extra( + (properties.getOrDefault("docker.tags", "") as String) + .split(',') + .filter { it.isNotEmpty() } + .toSet() + .let { tags -> + if (tags.isEmpty()) { + setOf("latest") + } else tags + } +) + +// Communicate with docker using Java client API. +@Suppress("unused") +val dockerClient: DockerClient by extra { + val configBuilder = DefaultDockerClientConfig.createDefaultConfigBuilder().build() + val httpClient = ApacheDockerHttpClient.Builder() + .dockerHost(configBuilder.dockerHost) + .sslConfig(configBuilder.sslConfig) + .build() + val dockerClient = DockerClientBuilder + .getInstance() + .withDockerHttpClient(httpClient) + .build() + project.gradle.buildFinished { + dockerClient.close() + } + dockerClient +} + +val installBinFmt by tasks.registering { + group = isleBuildkitGroup + description = "Install https://github.com/tonistiigi/binfmt to enable multi-arch builds on Linux." + // Cross building with Qemu is already installed with Docker Desktop so we only need to install on Linux hosts. + // Additionally it does not work with non x86_64 hosts. + onlyIf { + isContainerBuild && os.isLinux && arch.isAmd64 + } + doLast { + exec { + commandLine = listOf( + "docker", + "run", + "--rm", + "--privileged", + "tonistiigi/binfmt:qemu-v5.0.1", + "--install", "all" + ) + } + } +} + +// Local registry for use with the 'docker-container' driver. +val createLocalRegistry by tasks.registering { + group = isleBuildkitGroup + description = "Creates a local docker docker registry ('docker-container' or 'kubernetes' only)" + onlyIf { !isDockerBuild } + + val volume by extra(objects.property()) + volume.convention("isle-buildkit-registry") + + val network by extra(objects.property()) + network.convention("isle-buildkit") + + val configFile by extra(objects.fileProperty()) + configFile.convention(project.layout.buildDirectory.file("config.toml")) + + doLast { + // Create network (allows host DNS name resolution between builder and local registry). + network.get().let { + exec { + commandLine = listOf("docker", "network", "create", it) + isIgnoreExitValue = true // If it already exists it will return non-zero. + } + } + + // Create registry volume. + exec { + commandLine = listOf("docker", "volume", "create", volume.get()) + } + + // Check if the container is already running. + val running = ByteArrayOutputStream().use { output -> + exec { + commandLine = listOf("docker", "container", "inspect", "-f", "{{.State.Running}}", localRepository) + standardOutput = output + isIgnoreExitValue = true // May not be running. + }.exitValue == 0 && output.toString().trim().toBoolean() + } + // Start the local registry if not already started. + if (!running) { + exec { + commandLine = listOf( + "docker", + "run", + "-d", + "--restart=always", + "--network=isle-buildkit", + "--env", "REGISTRY_HTTP_ADDR=0.0.0.0:80", + "--env", "REGISTRY_STORAGE_DELETE_ENABLED=true", + "--name=$localRepository", + "--volume=${volume.get()}:/var/lib/registry", + "registry:2" + ) + } + } + // Allow insecure push / pull. + configFile.get().asFile.run { + parentFile.mkdirs() + writeText( + """ + [worker.oci] + enabled = true + [worker.containerd] + enabled = false + [registry."$localRepository"] + http = true + insecure = true + + """.trimIndent() + ) + } + } + mustRunAfter("destroyLocalRegistry") + finalizedBy("updateHostsFile") +} + +// Destroys resources created by createLocalRegistry. +val destroyLocalRegistry by tasks.registering { + group = isleBuildkitGroup + description = "Destroys the local registry and its backing volume" + doLast { + createLocalRegistry.get().let { task -> + val network: Property by task.extra + val volume: Property by task.extra + exec { + commandLine = listOf("docker", "rm", "-f", localRepository) + isIgnoreExitValue = true + } + exec { + commandLine = listOf("docker", "network", "rm", network.get()) + isIgnoreExitValue = true + } + exec { + commandLine = listOf("docker", "volume", "rm", volume.get()) + isIgnoreExitValue = true + } + } + } +} + +tasks.register("collectGarbageLocalRegistry") { + group = isleBuildkitGroup + description = "Deletes layers not referenced by any manifests in the local repository" + doLast { + exec { + commandLine = listOf("docker", + "exec", + localRepository, + "bin/registry", + "garbage-collect", + "/etc/docker/registry/config.yml") + } + } + dependsOn(createLocalRegistry) +} + +val getIpAddressOfLocalRegistry by tasks.registering { + val ipAddress by extra(objects.property()) + doLast { + ipAddress.set( + ByteArrayOutputStream().use { output -> + exec { + commandLine = listOf( + "docker", + "container", + "inspect", + "-f", + "{{range.NetworkSettings.Networks}}{{.IPAddress}}{{end}}", + localRepository + ) + standardOutput = output + } + output.toString().trim() + } + ) + } + dependsOn(createLocalRegistry) +} + +// Generally only needed for debugging local repository. +tasks.register("updateHostsFile") { + group = isleBuildkitGroup + description = "Modifies /etc/hosts to include reference to local repository on the host" + onlyIf { os.isLinux || os.isMacOsX } + doLast { + val ipAddress = getIpAddressOfLocalRegistry.get().let { task -> + val ipAddress: Property by task.extra + ipAddress.get() + } + exec { + // Removes any existing references to the local repository and appends local repository to the bottom. + standardInput = ByteArrayInputStream( + """ + sed -n \ + -e '/^.*${localRepository}/!p' \ + -e '${'$'}a${ipAddress}\t${localRepository}' \ + /etc/hosts > /tmp/hosts + cat /tmp/hosts > /etc/hosts + """.trimIndent().toByteArray() + ) + commandLine = listOf( + "docker", "run", "--rm", "-i", + "-v", "/etc/hosts:/etc/hosts", + "alpine:3.11.6", + "ash", "-s" + ) + } + } + dependsOn(getIpAddressOfLocalRegistry) +} + +// Allows building both x86_64 and arm64 using emulation supported in version 19.03 and up as well Docker Desktop. +val createBuilder by tasks.registering(DockerBuilder::class) { + group = isleBuildkitGroup + description = "Creates and starts the builder ('docker-container' or 'kubernetes' only)" + onlyIf { !isDockerBuild } + // Make sure the builder can find our local repository. + options.run { + append.set(true) + driver.set(buildDriver) + name.set("isle-buildkit-${buildDriver}") + node.set("isle-buildkit-${buildDriver}-node") + when (buildDriver) { + "docker-container" -> { + driverOpts.set(createLocalRegistry.map { task -> + val network: Property by task.extra + "network=${network.get()},image=moby/buildkit:v0.8.1" + }) + config.set(createLocalRegistry.map { task -> + val configFile: RegularFileProperty by task.extra + configFile.get() + }) + } + } + use.set(true) + } + // Make sure the build node has started as otherwise it will fail if we attempt to build to images concurrently. + // https://github.com/docker/buildx/issues/344 + doLast { + exec { + commandLine = listOf("docker", "buildx", "build", "-") + standardInput = ByteArrayInputStream( + """ + # syntax=docker/dockerfile:1.2.1 + FROM scratch + """.trimIndent().toByteArray() + ) + } + } + dependsOn(installBinFmt, createLocalRegistry) + mustRunAfter("destroyBuilder") +} + +val destroyBuilder by tasks.registering { + group = isleBuildkitGroup + description = "Destroy the builder and its cache ('docker-container' or 'kubernetes' only)" + doLast { + val builders = ByteArrayOutputStream().use { output -> + exec { + commandLine = listOf("docker", "buildx", "ls") + standardOutput = output + } + """^(isle-buildkit-[^ ]+)""".toRegex(RegexOption.MULTILINE).findAll(output.toString()).map { match -> + match.groupValues[1] + } + }.filterNotNull() + builders.forEach { builder -> + exec { + commandLine = listOf("docker", "buildx", "rm", builder) + isIgnoreExitValue = true + } + } + } +} + +val clean by tasks.registering { + group = isleBuildkitGroup + description = "Destroy absolutely everything" + doLast { + exec { + commandLine = listOf("docker", "system", "prune", "-f") + isIgnoreExitValue = true + } + } + dependsOn(destroyLocalRegistry, destroyBuilder) +} + +// Often easier to just use the default builder. +val useDefaultBuilder by tasks.registering { + group = isleBuildkitGroup + description = "Change the builder to use the Docker Daemon" + doLast { + project.exec { + commandLine = listOf("docker", "buildx", "use", "default") + } + } +} + +val setupBuilder by tasks.registering { + group = isleBuildkitGroup + description = "Setup the builder according to project properties" + when (buildDriver) { + "docker" -> dependsOn(useDefaultBuilder) + else -> dependsOn(createBuilder) + } +} + +tasks.all { + // Common settings for top level tasks. + if (group?.equals(isleBuildkitGroup) == true) { + logging.captureStandardOutput(LogLevel.INFO) + logging.captureStandardError(LogLevel.INFO) + } +} + +subprojects { + // Make all build directories relative to the root, only supports projects up to a depth of one for now. + buildDir = rootProject.buildDir.resolve(projectDir.relativeTo(rootDir)) + layout.buildDirectory.set(buildDir) + + // If there is a docker file in the project add the appropriate tasks. + if (isDockerProject) { + val tag = tags.first() + + val defaultTags = imageTags(repository) + val defaultBuildArgs = mapOf( + "repository" to repository, + "tag" to tag + ) + + val localRepositoryTags = imageTags(localRepository) + val localRepositoryBuildArgs = mapOf( + "repository" to localRepository, + "tag" to tag + ) + + tasks.register("build") { + group = isleBuildkitGroup + description = "Build docker image(s)" + options.run { + // If we are building with "docker-container" or "kubernetes" we must push as we need to be able to pull + // from from the registry when building downstream images. + push.set(!isDockerBuild) + // Force the tags / build args to be relative to our local repository. + if (!isDockerBuild && isLocalRepository) { + tags.set(localRepositoryTags) + buildArgs.set(localRepositoryBuildArgs) + } + mustRunAfter("delete") + } + } + + tasks.register("push") { + group = isleBuildkitGroup + description = "Build and push docker image(s)" + options.run { + push.set(true) + // Force the tags / build args to be relative to our local repository. + if (isLocalRepository) { + tags.set(localRepositoryTags) + buildArgs.set(localRepositoryBuildArgs) + } + } + mustRunAfter("delete") + } + + tasks.register("delete") { + group = isleBuildkitGroup + description = "Delete image(s) from local registry" + doLast { + val ipAddress = getIpAddressOfLocalRegistry.get().let { task -> + val ipAddress: Property by task.extra + ipAddress.get() + } + tags.plus("cache").map { tag -> + val baseUrl = "http://$ipAddress/v2/${project.name}/manifests" + (URL("$baseUrl/$tag").openConnection() as HttpURLConnection).run { + requestMethod = "GET" + setRequestProperty("Accept", "application/vnd.docker.distribution.manifest.v2+json") + headerFields["Docker-Content-Digest"]?.first() + }?.let { digest -> + logger.info("Deleting ${project.name}/$tag:$digest") + (URL("$baseUrl/$digest").openConnection() as HttpURLConnection).run { + requestMethod = "DELETE" + if (responseCode == 200 || responseCode == 202) { + logger.info("Successful ($responseCode): $responseMessage") + } else { + throw RuntimeException("Failed ($responseCode) - $responseMessage") + } + } + } + } + } + dependsOn(getIpAddressOfLocalRegistry) + } + + // Task groups all sub-project tasks.tests into single task. + tasks.register("test") { + group = isleBuildkitGroup + description = "Test docker image(s)" + dependsOn(project.subprojects.mapNotNull { it.tasks.matching { task -> task.name == "test" } }) + } + + // All build tasks have a number of shared defaults that can be overridden. + tasks.withType { + // Default arguments required for building. + options.run { + tags.convention(defaultTags) + buildArgs.convention(defaultBuildArgs) + } + // Require builder to build. + dependsOn(setupBuilder) + // If destroying resources as well make sure build tasks run after after the destroy tasks. + mustRunAfter(clean, destroyBuilder, destroyLocalRegistry) + } + } +} diff --git a/src/main/kotlin/ca.islandora.gradle.docker.gradle.kts b/src/main/kotlin/ca.islandora.gradle.docker.gradle.kts deleted file mode 100644 index 8d938d6..0000000 --- a/src/main/kotlin/ca.islandora.gradle.docker.gradle.kts +++ /dev/null @@ -1,290 +0,0 @@ -import com.bmuschko.gradle.docker.tasks.image.DockerPushImage -import com.bmuschko.gradle.docker.tasks.image.Dockerfile -import com.bmuschko.gradle.docker.tasks.image.Dockerfile.* -import java.io.ByteArrayOutputStream - -plugins { - id("com.bmuschko.docker-remote-api") -} - -extensions.findByName("buildScan")?.withGroovyBuilder { - setProperty("termsOfServiceUrl", "https://gradle.com/terms-of-service") - setProperty("termsOfServiceAgree", "yes") -} - -val useBuildKit = properties.getOrDefault("useBuildKit", "true") as String -val repository = properties.getOrDefault("repository", "local") as String -val cacheRepository = properties.getOrDefault("cacheRepository", "islandora") as String - -val registryUrl = properties.getOrDefault("registryUrl", "https://index.docker.io/v1") as String -val registryUsername = properties.getOrDefault("registryUsername", "") as String -val registryPassword = properties.getOrDefault("registryPassword", "") as String - -// https://docs.docker.com/engine/reference/builder/#from -// FROM [--platform=] [AS ] -// FROM [--platform=] [:] [AS ] -// FROM [--platform=] [@] [AS ] -val extractProjectDependenciesFromDockerfileRegex = - """FROM[ \t]+(:?--platform=[^ ]+[ \t]+)?local/([^ :@]+):(.*)""".toRegex() - -// If BuildKit is enabled instructions are left as is otherwise BuildKit specific flags are removed. -val extractBuildKitFlagFromInstruction = """(--mount.+ \\)""".toRegex() -val preprocessRunInstruction: (Instruction) -> Instruction = if (useBuildKit.toBoolean()) { - // No-op - { instruction -> instruction } -} else { - // Strip BuildKit specific flags. - { instruction -> - // Assumes only mount flags are used and each one is separated onto it's own line. - val text = instruction.text.replace(extractBuildKitFlagFromInstruction, """\\""") - GenericInstruction(text) - } -} - -data class BindMount(val from: String?, val source: String?, val target: String) { - companion object { - private val EXTRACT_BIND_MOUNT_REGEX = """--mount=type=bind,([^\\]+)""".toRegex() - - fun fromInstruction(instruction: Instruction) = EXTRACT_BIND_MOUNT_REGEX.find(instruction.text)?.let { - val properties = it.groupValues[1].split(',').map { property -> - val parts = property.split('=') - Pair(parts[0], parts[1]) - }.toMap() - BindMount(properties["from"], properties["source"], properties["target"]!!) - } - } - - // eg. COPY /packages - // eg. COPY /home/builder/packages/x86_64 /packages - // eg. COPY --from=imagemagick /home/builder/packages/x86_64 /packages - fun toCopyInstruction(): GenericInstruction { - val from = if (from != null) "--from=${from}" else "" - return GenericInstruction("COPY $from $source $target") - } -} - -// Generate a list of image tags for the given image, using the project, and tag properties. -fun imagesTags(image: String, project: Project): Set { - val tags = properties.getOrDefault("tags", "") as String - return setOf("$image:latest") + tags.split(' ').filter { it.isNotEmpty() }.map { "$image:$it" } -} - -fun imageExists(project: Project, imageIdFile: RegularFileProperty) = try { - val imageId = imageIdFile.asFile.get().readText() - val result = project.exec { - commandLine = listOf("docker", "image", "inspect", imageId) - // Ignore output, only concerned with exit value. - standardOutput = ByteArrayOutputStream() - errorOutput = ByteArrayOutputStream() - } - result.exitValue == 0 -} catch (e: Exception) { - false -} - -subprojects { - // Make all build directories relative to the root, only supports projects up to a depth of one for now. - buildDir = rootProject.buildDir.resolve(projectDir.relativeTo(rootDir)) - layout.buildDirectory.set(buildDir) - - // If there is a docker file in the project add the appropriate tasks. - if (projectDir.resolve("Dockerfile").exists()) { - apply(plugin = "com.bmuschko.docker-remote-api") - - val imageTags = imagesTags("$repository/$name", project) - val cachedImageTags = imagesTags("$cacheRepository/$name", project) - - val createDockerfile = tasks.register("createDockerFile") { - instructionsFromTemplate(projectDir.resolve("Dockerfile")) - // To simplify processing the instructions group them by keyword. - val originalInstructions = instructions.get().toList() - val groupedInstructions = mutableListOf>>( - Pair(originalInstructions.first().keyword, mutableListOf(originalInstructions.first())) - ) - originalInstructions.drop(1).forEach { instruction -> - // An empty keyword means the line of text belongs to the previous instruction keyword. - if (instruction.keyword != "") { - groupedInstructions.add(Pair(instruction.keyword, mutableListOf(instruction))) - } else { - groupedInstructions.last().second.add(instruction) - } - } - // Using bind mounts from other images needs to be mapped to COPY instructions, if not using BuildKit. - // Add these COPY instructions prior to the RUN instructions that used the bind mount. - val iterator = groupedInstructions.listIterator() - while (iterator.hasNext()) { - val (keyword, instructions) = iterator.next() - when (keyword) { - RunCommandInstruction.KEYWORD -> if (!useBuildKit.toBoolean()) { // Convert bind mounts to copies when BuildKit is not enabled. - // Get any bind mount flags and convert them into copy instructions. - val bindMounts = instructions.mapNotNull { instruction -> - BindMount.fromInstruction(instruction) - } - bindMounts.forEach { bindMount -> - // Add before RUN instruction, previous is safe here as there has to always be at least a - // single FROM instruction preceding it. - iterator.previous() - iterator.add( - Pair( - CopyFileInstruction.KEYWORD, - mutableListOf(bindMount.toCopyInstruction()) - ) - ) - iterator.next() - } - } - } - } - // Process instructions in place, and flatten to list. - val processedInstructions = groupedInstructions.flatMap { (keyword, instructions) -> - when (keyword) { - // Use the 'repository' name for the images when building, defaults to 'local'. - FromInstruction.KEYWORD -> { - instructions.map { instruction -> - extractProjectDependenciesFromDockerfileRegex.find(instruction.text)?.let { - val name = it.groupValues[2] - val remainder = it.groupValues[3] - FromInstruction(From("$repository/$name:$remainder")) - } ?: instruction - } - } - // Strip BuildKit flags if applicable. - RunCommandInstruction.KEYWORD -> instructions.map { preprocessRunInstruction(it) } - else -> instructions - } - } - instructions.set(processedInstructions) - destFile.set(buildDir.resolve("Dockerfile")) - } - - val buildDockerImage = tasks.register("build") { - group = "islandora" - description = "Creates Docker image." - dockerFile.set(createDockerfile.map { it.destFile.get() }) - buildKit.set(useBuildKit.toBoolean()) - images.addAll(imageTags) - inputDir.set(projectDir) - // Use the remote cache to build this image if possible. - cacheFrom.addAll(cachedImageTags) - // Check that another process has not removed the image since it was last built. - outputs.upToDateWhen { task -> - imageExists(project, (task as DockerBuildKitBuildImage).imageIdFile) - } - } - - tasks.register("push") { - images.set(buildDockerImage.map { it.images.get() }) - registryCredentials { - url.set(registryUrl) - username.set(registryUsername) - password.set(registryPassword) - } - } - } -} - -inline fun getBuildDependencies(childTask: T) = childTask.project.run { - val contents = projectDir.resolve("Dockerfile").readText() - // Extract the image name without the prefix 'local' it should match an existing project. - val matches = extractProjectDependenciesFromDockerfileRegex.findAll(contents) - - // If the project is found and it has a build task, it is a dependency. - matches.mapNotNull { - rootProject.findProject(it.groupValues[2])?.tasks?.withType() - }.flatten() -} - -// This used to replace the FROM statements such that the referred to the Image ID rather -// than "latest". Though this is currently broken when BuildKit is enabled: -// https://github.com/moby/moby/issues/39769 -// Now it uses whatever repository we're building / latest since that is variable. -subprojects { - tasks.withType { - getBuildDependencies(this).forEach { parentTask -> - inputs.file(parentTask.imageIdFile.asFile) // If generated image id changes, rebuild. - dependsOn(parentTask) - } - } -} - -//============================================================================= -// Helper functions. -//============================================================================= - -// Override the DockerBuildImage command to use the CLI since BuildKit is not supported in the java docker api. -// https://github.com/docker-java/docker-java/issues/1381 -open class DockerBuildKitBuildImage : DefaultTask() { - @InputFile - @PathSensitive(PathSensitivity.RELATIVE) - val dockerFile = project.objects.fileProperty() - - @InputDirectory - @PathSensitive(PathSensitivity.RELATIVE) - val inputDir = project.objects.directoryProperty() - - @Input - val buildKit = project.objects.property() - - @Input - @get:Optional - val cacheFrom = project.objects.listProperty() - - @Input - @get:Optional - val buildArgs = project.objects.mapProperty() - - @Input - @get:Optional - val images = project.objects.setProperty() - - @OutputFile - val imageIdFile = project.objects.fileProperty() - - @Internal - val imageId = project.objects.property() - - init { - logging.captureStandardOutput(LogLevel.INFO) - logging.captureStandardError(LogLevel.ERROR) - imageIdFile.set(project.buildDir.resolve("${path.replace(":", "_")}-imageId.txt")) - } - - private fun cacheFromValid(image: String): Boolean { - return try { - val result = project.exec { - environment("DOCKER_CLI_EXPERIMENTAL", "enabled") - workingDir = inputDir.get().asFile - commandLine = listOf("docker", "manifest", "inspect", image) - } - result.exitValue == 0 - } catch (e: Exception) { - logger.error("Failed to find cache image: ${image}, either it does not exist, or authentication failed.") - false - } - } - - @TaskAction - fun exec() { - val command = mutableListOf("docker", "build", "--progress=plain") - command.addAll(listOf("--file", dockerFile.get().asFile.absolutePath)) - if (buildKit.get()) { - // Only BuildKit allows us to use existing images as a cache. - command.addAll(cacheFrom.get().filter { cacheFromValid(it) }.flatMap { listOf("--cache-from", it) }) - // Allow image to be used as a cache when building on other machine. - command.addAll(listOf("--build-arg", "BUILDKIT_INLINE_CACHE=1")) - } - command.addAll(buildArgs.get().flatMap { listOf("--build-arg", "${it.key}=${it.value}") }) - command.addAll(images.get().flatMap { listOf("--tag", it) }) - command.addAll(listOf("--iidfile", imageIdFile.get().asFile.absolutePath)) - command.add(".") - project.exec { - // Use BuildKit to build. - if (buildKit.get()) { - environment("DOCKER_BUILDKIT" to 1) - } - workingDir = inputDir.get().asFile - commandLine = command - } - imageId.set(imageIdFile.map { it.asFile.readText() }) - } -} diff --git a/src/main/kotlin/tasks/DockerBuild.kt b/src/main/kotlin/tasks/DockerBuild.kt new file mode 100644 index 0000000..ff7579c --- /dev/null +++ b/src/main/kotlin/tasks/DockerBuild.kt @@ -0,0 +1,343 @@ +package tasks + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.github.dockerjava.api.DockerClient +import com.github.dockerjava.api.command.RootFS +import com.github.dockerjava.api.exception.NotFoundException +import com.github.dockerjava.api.model.ContainerConfig +import com.sun.org.apache.xpath.internal.operations.Bool +import org.gradle.api.DefaultTask +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.model.ObjectFactory +import org.gradle.api.tasks.* +import org.gradle.kotlin.dsl.* +import utils.DockerCommandOptions +import utils.DockerCommandOptions.Option +import utils.imageTags + +// Wrapper around a call to `docker buildx build`, please refer to the documentation for more information: +// https://github.com/docker/buildx#documentation +@Suppress("UnstableApiUsage") +@CacheableTask +open class DockerBuild : DefaultTask() { + + // Not actually the image digest but rather an approximation that ignores timestamps, etc. + // So we do not build/test unless the image has actually changed, it only checks contents & configuration. + data class ApproximateDigest(val config: ContainerConfig, val rootFS: RootFS) + + class Options constructor(objects: ObjectFactory) : DockerCommandOptions { + // Add a custom host-to-IP mapping (host:ip) + @Input + @Optional + @Option("--add-host") + val addHosts = objects.listProperty() + + // Allow extra privileged entitlement, e.g. network.host, security.insecure + @Input + @Optional + @Option("--allow") + val allows = objects.listProperty() + + // Set build-time variables + @Input + @Optional + @Option("--build-arg") + val buildArgs = objects.mapProperty() + + // Override the configured builder instance + @Input + @Optional + @Option("--builder") + val builder = objects.property() + + // External cache sources (eg. user/app:cache,type=local,src=path/to/dir) + @Input + @Optional + @Option("--cache-from") + val cacheFrom = objects.setProperty() + + // Cache export destinations (eg. user/app:cache,type=local,dest=path/to/dir) + @Input + @Optional + @Option("--cache-to") + val cacheTo = objects.setProperty() + + // Name of the Dockerfile (Default is 'PATH/Dockerfile') + @InputFile + @PathSensitive(PathSensitivity.RELATIVE) + @Optional + @Option("--file") + val dockerFile = objects.fileProperty() + + // Write the image ID to the file + @OutputFile + @Option("--iidfile") + val imageIdFile = objects.fileProperty() + + // --label stringArray + // Set metadata for an image + @Input + @Optional + @Option("") + val labels = objects.mapProperty() + + // Shorthand for --output=type=docker + @Input + @Optional + @Option("--load") + val load = objects.property().convention(false) + + // Set the networking mode for the RUN instructions during build (default "default") + @Input + @Optional + @Option("--network") + val network = objects.property() + + // Do not use cache when building the image + @Input + @Optional + @Option("--no-cache") + val noCache = objects.property().convention(false) + + // Output destination (format: type=local,dest=path) + @Input + @Optional + @Option("--output") + val output = objects.listProperty() + + // Set target platform for build + @Input + @Optional + @Option("--platform") + val platforms = objects.listProperty() + + // Set type of progress output (auto, plain, tty) + // Use plain to show container output + @Input + @Optional + @Option("--progress") + val progress = objects.property().convention("plain") + + // Always attempt to pull a newer version of the image + @Input + @Optional + @Option("--pull") + val pull = objects.property().convention(false) + + // Shorthand for --output=type=registry + @Input + @Optional + @Option("--push") + val push = objects.property().convention(false) + + // Secret file to expose to the build: id=mysecret,src=/local/secret + @Input + @Optional + @Option("--secret") + val secrets = objects.listProperty() + + // SSH agent socket or keys to expose to the build (format: default|[=|[,]]) + @Input + @Optional + @Option("--ssh") + val ssh = objects.listProperty() + + // Name and optionally a tag in the 'name:tag' format + @Input + @Optional + @Option("--tag") + val tags = objects.listProperty() + + } + + @Nested + val options = Options(project.objects) + + // PATH (i.e. Docker build context) + @InputFiles + @PathSensitive(PathSensitivity.RELATIVE) + val context = project.objects.fileTree().setDir(project.layout.projectDirectory) + + // A json file whose contents can be used to uniquely identify an image by contents. We do not actually need to + // generate a hash as Gradle will do that when computing dependencies between tasks. This is only used to prevent + // building / testing when the upstream images have not changed. + @OutputFile + val digest = project.objects.fileProperty().convention(project.layout.buildDirectory.file("${name}-digest.json")) + + // Gets list of images names without repository or tag, required to build this image. + // This assumes all images are building within this project. + private val requiredImages = options.dockerFile.map { file -> + file.asFile.readText().let { text -> + ("\\" + '$' + """\{repository\}/(?[^:@]+)""") + .toRegex() + .findAll(text) + .map { it.groups["image"]!!.value } + .toSet() + } + } + + // The approximate digests of images required to build this one. This is only used to prevent building / testing + // when the upstream images have not changed. + @InputFiles + @PathSensitive(PathSensitivity.RELATIVE) + val sourceImageDigests = project.objects.listProperty().convention( + requiredImages.map { images -> + images.map { image -> + dockerBuildTasks(name)[image]!!.get().digest + } + } + ) + + init { + // Exclude changes to files/directories mentioned in the Docker ignore file. + val ignore = context.dir.resolve(".dockerignore") + if (ignore.exists()) { + ignore.readLines().forEach { line -> + context.exclude(line) + } + } + + options.run { + // Get project properties used to set defaults. + val buildPlatforms: Set by project.rootProject.extra + val cacheFromEnabled: Boolean by project.rootProject.extra + val cacheToEnabled: Boolean by project.rootProject.extra + val cacheFromRepositories: Set by project.rootProject.extra + val cacheToRepositories: Set by project.rootProject.extra + val cacheToMode: String by project.rootProject.extra + val noBuildCache: Boolean by project.rootProject.extra + val isDockerBuild: Boolean by project.rootProject.extra + + // Assume docker file is in the project directory. + dockerFile.convention(project.layout.projectDirectory.file("Dockerfile")) + // We always want to generate an imageIdFile if applicable i.e. when --load is specified. + imageIdFile.convention(project.layout.buildDirectory.file("${name}-imageId.txt")) + // It is not possible to use --platform with "docker" builder. + if (!isDockerBuild) { + platforms.convention(buildPlatforms) + } + // Load if no platform is given as it will be the host platform so we can safely `load`. + // Also cannot load while pushing: https://github.com/docker/buildx/issues/177 + load.convention(platforms.map { !push.get() && it.isEmpty() }) + if (!noBuildCache) { + // Cache from user provided repositories. + if (cacheFromEnabled) { + cacheFrom.convention( + cacheFromRepositories.map { repository -> + "type=registry,ref=$repository/${project.name}:cache" + } + ) + } + // Cache to registry or cache inline. + if (cacheToEnabled) { + cacheTo.convention( + cacheToRepositories.map { repository -> + when (cacheToMode) { + "min", "max" -> "type=registry,mode=${cacheToMode},ref=$repository/${project.name}:cache" + "inline" -> "type=inline" + else -> throw RuntimeException("Unknown cacheToMode $cacheToMode") + } + } + ) + } + } + // Enable / disable the local build cache. + noCache.convention(noBuildCache) + } + + // Check that another process has not removed the image since it was last built. + outputs.upToDateWhen { task -> (task as DockerBuild).imagesExist() } + } + + // Get list of all DockerBuild tasks with the given name. + private fun dockerBuildTasks(name: String) = project.rootProject.allprojects + .filter { it.projectDir.resolve("Dockerfile").exists() } + .map { project -> + project.name to project.tasks.named(name) + } + .toMap() + + // Checks if all images denoted by the given tag(s) exists locally. + private fun imagesExist(): Boolean { + val dockerClient: DockerClient by project.rootProject.extra + return options.tags.get().all { tag -> + try { + dockerClient.inspectImageCmd(tag).exec() + true + } catch (e: NotFoundException) { + false + } + } + } + + // --iidfile is only generated when the image is exported to `docker images` + // i.e. with `--load` is specified. + private val shouldCreateImageIdFile + get() = options.load.get() + + // Execute the Docker build command. + private fun build() { + val exclude = if (!shouldCreateImageIdFile) listOf("--iidfile") else emptyList() + val options = options.toList(exclude) + project.exec { + workingDir = context.dir + commandLine = listOf("docker", "buildx", "build") + options + listOf(context.dir.absolutePath) + } + } + + // "push and load may not be set together at the moment", so we must manually load after building. + private fun load() { + val isDockerBuild: Boolean by project.rootProject.extra + val isLocalRepository: Boolean by project.rootProject.extra + val multiArch = options.platforms.get().size > 1 + if (!isDockerBuild && multiArch) { + val exclude = listOfNotNull( + "--iidfile", + "--platform", + "--push", + "--cache-from", + "--cache-to" + ) + val command = mutableListOf("docker", "buildx", "build") + command.addAll(options.toList(exclude)) + command.add("--load") + if (isLocalRepository) { + command.addAll(project.imageTags("local").flatMap { listOf("--tag", it) }) + } + command.add(context.dir.absolutePath) + project.exec { + workingDir = context.dir + commandLine = command + } + } + } + + // Due to https://github.com/docker/buildx/issues/420 we cannot rely on the imageId file to be populated + // correctly so we take matters into our own hands. + private fun updateImageFile() { + val isDockerBuild: Boolean by project.rootProject.extra + val dockerClient: DockerClient by project.rootProject.extra + if (!isDockerBuild) { + dockerClient.inspectImageCmd(options.tags.get().first()).exec().run { + options.imageIdFile.get().asFile.writeText(id) + } + } + } + + // We generate an approximate digest to prevent rebuilding downstream images as this will be used as an input to + // those images. + private fun updateDigest() { + val dockerClient: DockerClient by project.rootProject.extra + dockerClient.inspectImageCmd(options.tags.get().first()).exec().run { + digest.get().asFile.writeText(jacksonObjectMapper().writeValueAsString(ApproximateDigest(config, rootFS))) + } + } + + @TaskAction + fun exec() { + build() + load() + updateDigest() + updateImageFile() + } +} diff --git a/src/main/kotlin/tasks/DockerBuilder.kt b/src/main/kotlin/tasks/DockerBuilder.kt new file mode 100644 index 0000000..221a6d7 --- /dev/null +++ b/src/main/kotlin/tasks/DockerBuilder.kt @@ -0,0 +1,93 @@ +package tasks + +import org.gradle.api.DefaultTask +import org.gradle.api.model.ObjectFactory +import org.gradle.api.tasks.* +import org.gradle.kotlin.dsl.property +import utils.DockerCommandOptions +import utils.DockerCommandOptions.Option + +// Wrapper around a call to `docker buildx create`, please refer to the documentation for more information: +// https://github.com/docker/buildx#documentation +@Suppress("UnstableApiUsage") +open class DockerBuilder : DefaultTask() { + + class Options constructor(objects: ObjectFactory) : DockerCommandOptions { + // Append a node to builder instead of changing it + @Input + @Optional + @Option("--append") + val append = objects.property().convention(false) + + // Override the configured builder instance + @Input + @Optional + @Option("--builder") + val builder = objects.property() + + // Flags for buildkitd daemon + @Input + @Optional + @Option("--buildkitd-flags") + val buildkitdFlags = objects.property() + + // BuildKit config file + @InputFile + @PathSensitive(PathSensitivity.RELATIVE) + @Optional + @Option("--config") + val config = objects.fileProperty() + + // Driver to use (available: [docker docker-container kubernetes]) + @Input + @Optional + @Option("--driver") + val driver = objects.property() + + // Options for the driver + @Input + @Optional + @Option("--driver-opt") + val driverOpts = objects.property() + + // Remove a node from builder instead of changing it + @Input + @Optional + @Option("--leave") + val leave = objects.property().convention(false) + + // Builder instance name + @Input + @Optional + @Option("--name") + val name = objects.property() + + // Create/modify node with given name + @Input + @Optional + @Option("--node") + val node = objects.property() + + // Fixed platforms for current node + @Input + @Optional + @Option("--platform") + val platform = objects.property() + + @Input + @Optional + @Option("--use") + val use = objects.property() + } + + @Nested + val options = Options(project.objects) + + @TaskAction + fun exec() { + project.exec { + workingDir = project.projectDir + commandLine = listOf("docker", "buildx", "create") + options.toList() + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/tasks/DockerClient.kt b/src/main/kotlin/tasks/DockerClient.kt new file mode 100644 index 0000000..decee0f --- /dev/null +++ b/src/main/kotlin/tasks/DockerClient.kt @@ -0,0 +1,48 @@ +package tasks + +import com.github.dockerjava.api.DockerClient +import com.github.dockerjava.core.DefaultDockerClientConfig +import com.github.dockerjava.core.DockerClientBuilder +import com.github.dockerjava.httpclient5.ApacheDockerHttpClient +import org.gradle.api.DefaultTask +import org.gradle.api.UnknownTaskException +import org.gradle.api.tasks.Internal +import org.gradle.kotlin.dsl.* + +abstract class DockerClient : DefaultTask() { + + @get:Internal + val dockerClient: DockerClient by lazy { + val configBuilder = DefaultDockerClientConfig.createDefaultConfigBuilder().build() + val httpClient = ApacheDockerHttpClient.Builder() + .dockerHost(configBuilder.dockerHost) + .sslConfig(configBuilder.sslConfig) + .build() + val dockerClient = DockerClientBuilder + .getInstance() + .withDockerHttpClient(httpClient) + .build() + project.gradle.buildFinished { + dockerClient.close() + } + dockerClient + } + + private val parents by lazy { + project.run { + generateSequence(this) { it.parent } + } + } + + @get:Internal + protected val buildTask by lazy { + parents.forEach { + try { + return@lazy it.tasks.named("build") + } catch (e: UnknownTaskException) { + } + } + return@lazy null + } + +} \ No newline at end of file diff --git a/src/main/kotlin/tasks/DockerCompose.kt b/src/main/kotlin/tasks/DockerCompose.kt new file mode 100644 index 0000000..6b98ac3 --- /dev/null +++ b/src/main/kotlin/tasks/DockerCompose.kt @@ -0,0 +1,197 @@ +package tasks + +import com.fasterxml.jackson.core.JsonParser +import com.fasterxml.jackson.databind.DeserializationFeature +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory +import com.fasterxml.jackson.module.kotlin.KotlinModule +import com.fasterxml.jackson.module.kotlin.readValue +import com.github.dockerjava.api.command.InspectContainerResponse +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.tasks.* +import org.gradle.kotlin.dsl.listProperty +import org.gradle.kotlin.dsl.mapProperty +import org.gradle.kotlin.dsl.provideDelegate +import java.io.ByteArrayOutputStream +import java.io.File +import java.io.OutputStream +import java.time.Duration + +@Suppress("UnstableApiUsage", "MemberVisibilityCanBePrivate") +@CacheableTask +abstract class DockerCompose : DockerClient() { + + data class DockerComposeFile(val services: Map) { + companion object { + fun deserialize(file: File): DockerComposeFile = ObjectMapper(YAMLFactory()) + .registerModule(KotlinModule()) + .configure(JsonParser.Feature.ALLOW_UNQUOTED_FIELD_NAMES, true) + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + .readValue(file) + } + } + + data class Service(val image: String) { + companion object { + val regex = """\$\{(?[^:]+):-(?.+)\}""".toRegex() + } + + private fun variable() = regex + .matchEntire(image) + ?.groups + ?.get("variable") + ?.value + + fun env(image: String) = + variable() + ?.let { variable -> + variable to image + } + } + + // The docker-compose.yml file to run. + @InputFile + @PathSensitive(PathSensitivity.RELATIVE) + val dockerComposeFile = + project.objects.fileProperty().convention(project.layout.projectDirectory.file("docker-compose.yml")) + + @get:Internal + val dockerCompose by lazy { + DockerComposeFile.deserialize(dockerComposeFile.get().asFile) + } + + // Environment variables which allow us to override the image used by the service. + @get:Internal + val imageEnvironmentVariables by lazy { + dockerCompose.services.mapNotNull { (name, service) -> + project.findProject(":$name") + ?.tasks + ?.named("build", DockerBuild::class.java) + ?.get() + ?.options + ?.tags + ?.get() + ?.first() + ?.let { image -> + service.env(image) + } + }.toMap() + } + + // Not actually the image digest but rather an approximation that ignores timestamps, etc. + // So we do not run test unless the image has actually changed. + @InputFiles + @Optional + @PathSensitive(PathSensitivity.RELATIVE) + val digests = project.objects.listProperty().convention(project.provider { + // If the name of a service matches a known image in this build we will set a dependency on it. + dockerCompose.services.mapNotNull { (name, _) -> + project.findProject(":$name") + ?.tasks + ?.named("build", DockerBuild::class.java) + ?.get() + ?.digest + } + }) + + // Capture the log output of the command for later inspection. + @OutputFile + val log = project.objects.fileProperty().convention(project.layout.buildDirectory.file("${name}.log")) + + // Environment for docker-compose not the actual containers. + @Input + val environment = project.objects.mapProperty() + + @Internal + val info = project.objects.mapProperty() + + init { + // Rerun test if any of the files in the directory of the docker-compose.yml file changes, as likely they are + // bind mounted or secrets, etc. The could affect the outcome of the test. + inputs.dir(project.projectDir) + // By default limit max execution time to a minute. + timeout.convention(Duration.ofMinutes(5)) + // Ensure we do not leave container running if something goes wrong. + project.gradle.buildFinished { + ByteArrayOutputStream().use { output -> + invoke("down", "-v", output = output, error = output) + logger.info(output.toString()) + } + } + } + + fun invoke( + vararg args: String, + env: Map = imageEnvironmentVariables.plus(environment.get()), + ignoreExitValue: Boolean = false, + output: OutputStream? = null, + error: OutputStream? = null + ) = project.exec { + environment.putAll(env) + workingDir = dockerComposeFile.get().asFile.parentFile + isIgnoreExitValue = ignoreExitValue + if (output != null) standardOutput = output + if (error != null) errorOutput = error + commandLine("docker-compose", "--project-name", project.path.replace(":", "_"), *args) + } + + fun up(vararg args: String, ignoreExitValue: Boolean = false) = try { + invoke("up", *args, ignoreExitValue = ignoreExitValue) + } catch (e: Exception) { + log() + throw e + } + + fun exec(vararg args: String) = invoke("exec", *args) + + fun stop(vararg args: String) = invoke("stop", *args) + + fun down(vararg args: String) = invoke("down", *args) + + fun pull() = dockerCompose.services.keys.mapNotNull { name -> + // Find services that do not match any projects and pull them as they must refer to an external image. + // Other images will be provided by dependency on the image digests. + if (project.rootProject.findProject(":$name") == null) name else null + }.let { services -> + if (services.isNotEmpty()) { + invoke("pull", *services.toTypedArray()) + } + } + + fun log() { + log.get().asFile.outputStream().buffered().use { writer -> + invoke("logs", "--no-color", "--timestamps", output = writer, error = writer) + } + } + + fun inspect() = ByteArrayOutputStream().use { output -> + invoke("ps", "-aq", output = output) + output + .toString() + .lines() + .filter { it.isNotEmpty() } + .map { container -> + dockerClient.inspectContainerCmd(container).exec() + } + } + + fun setUp() { + pull() + } + + fun tearDown() { + stop() + info.set(inspect().map { it.config.labels["com.docker.compose.service"]!! to it }.toMap()) + log() + down("-v") + } + + fun checkExitCodes(expected: Long) { + info.get().forEach { (name, info) -> + val state = info.state + if (state.exitCodeLong != expected) { + throw RuntimeException("Service $name exited with ${state.exitCodeLong} and status ${state.status}.") + } + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/tasks/DockerContainer.kt b/src/main/kotlin/tasks/DockerContainer.kt new file mode 100644 index 0000000..98651fc --- /dev/null +++ b/src/main/kotlin/tasks/DockerContainer.kt @@ -0,0 +1,186 @@ +package tasks + +import com.github.dockerjava.api.async.ResultCallback +import com.github.dockerjava.api.command.CreateContainerCmd +import com.github.dockerjava.api.command.InspectContainerResponse +import com.github.dockerjava.api.exception.NotFoundException +import com.github.dockerjava.api.exception.NotModifiedException +import com.github.dockerjava.api.model.Frame +import org.gradle.api.tasks.* +import org.gradle.kotlin.dsl.property +import java.time.Duration.ofMinutes +import java.time.Instant +import java.time.format.DateTimeFormatter +import kotlin.concurrent.thread + +@Suppress("UnstableApiUsage", "MemberVisibilityCanBePrivate") +@CacheableTask +abstract class DockerContainer : DockerClient() { + + // Not marked as input as the tag can change but the image contents may be the same and we do not need to rerun. + @Internal + val imageId = project.objects.property() + + // Not actually the image digest but rather an approximation that ignores timestamps, etc. + // So we do not run test unless the image has actually changed. + @InputFile + @Optional + @PathSensitive(PathSensitivity.RELATIVE) + val digest = project.objects.fileProperty() + + // Capture the log output of the command for later inspection. + @OutputFile + val log = project.objects.fileProperty().convention(project.layout.buildDirectory.file("${name}.log")) + + // Identifier of the container started to run this task. + @Suppress("ANNOTATION_TARGETS_NON_EXISTENT_ACCESSOR") + @get:Internal + val containerId = project.objects.property() + + @Internal + val info = project.objects.property() + + init { + // Rerun test if any of the files in the directory changes, as likely they are + // bind mounted or secrets, etc. The could affect the outcome of the test. + inputs.dir(project.projectDir) + + // By default limit max execution time to a minute. + timeout.convention(ofMinutes(5)) + + // If there is a parent project with a build task assume that docker image is the one we want to use unless + // specified otherwise. + buildTask?.let { + val task = it.get() + imageId.convention(task.options.tags.get().first()) + digest.convention(task.digest) + } + + // Ensure we do not leave container running if something goes wrong. + project.gradle.buildFinished { + // May be called before creation of container if build is cancelled etc. + if (containerId.isPresent) { + remove(true) + } + } + } + + // To be able to update the log and view after completion we need it on a separate thread. + @get:Internal + val loggingThread by lazy { + thread(start = false) { + log.get().asFile.bufferedWriter().use { writer -> + dockerClient.logContainerCmd(containerId.get()) + .withFollowStream(true) + .withStdOut(true) + .withStdErr(true) + .exec(object : ResultCallback.Adapter() { + override fun onNext(frame: Frame) { + val timestamp = DateTimeFormatter.ISO_INSTANT.format(Instant.now()) + val payload = String(frame.payload).trim { it <= ' ' } + val line = "[$timestamp] ${frame.streamType}: $payload" + logger.info(line) + writer.write("$line\n") + } + }).awaitCompletion() + } + } + } + + fun create() { + if (!containerId.isPresent) { + containerId.set(dockerClient.createContainerCmd(imageId.get()).exec().id) + } + } + + fun create(action: CreateContainerCmd.() -> CreateContainerCmd) { + if (!containerId.isPresent) { + dockerClient.createContainerCmd(imageId.get()).let { + containerId.set(action(it).exec().id) + } + } + } + + fun start() { + try { + dockerClient.startContainerCmd(containerId.get()).exec() + } catch (e: NotModifiedException) { + // Ignore if container has already started. + } + loggingThread.start() + } + + fun stop() { + try { + dockerClient.stopContainerCmd(containerId.get()).exec() + } catch (e: NotModifiedException) { + // Ignore if not modified, as it has already been stopped. + } catch (e: Exception) { + // Unrecoverable error, user will have to clean up their environment. + throw e + } + loggingThread.join() // Container has stopped finish logging. + } + + fun wait() = + dockerClient.waitContainerCmd(containerId.get()).exec(ResultCallback.Adapter())?.awaitCompletion() + + fun inspect(): InspectContainerResponse = dockerClient.inspectContainerCmd(containerId.get()).exec() + + fun remove(force: Boolean = false) { + try { + dockerClient + .removeContainerCmd(containerId.get()) + .withForce(force) + .exec() + } catch (e: NotFoundException) { + // Ignore if not found, as it has already been removed. + } catch (e: Exception) { + // Unrecoverable error, user will have to clean up their environment. + throw e + } + } + + // Executes callback for each line of log output until the stream ends or the callback returns false. + fun untilOutput(callback: (String) -> Boolean) { + dockerClient.logContainerCmd(containerId.get()) + .withTailAll() + .withFollowStream(true) + .withStdOut(true) + .withStdErr(true) + .exec(object : ResultCallback.Adapter() { + override fun onNext(frame: Frame) { + val line = String(frame.payload) + if (!callback(line)) { + close() + } + super.onNext(frame) + } + })?.awaitCompletion() + } + + fun setUp() { + create() + start() + + } + + fun setUp(action: CreateContainerCmd.() -> CreateContainerCmd) { + create(action) + start() + } + + fun tearDown() { + stop() + info.set(inspect()) + remove(true) + } + + fun checkExitCode(expected: Long) { + val name = info.get().name + val state = info.get().state + if (state.exitCodeLong != expected) { + throw RuntimeException("Container Image: '${imageId.get()}' Name: '$name' ID: '${containerId.get()}' exited with ${state.exitCodeLong} and status ${state.status}.") + } + } +} diff --git a/src/main/kotlin/tasks/tests/DockerComposeTest.kt b/src/main/kotlin/tasks/tests/DockerComposeTest.kt new file mode 100644 index 0000000..21992f9 --- /dev/null +++ b/src/main/kotlin/tasks/tests/DockerComposeTest.kt @@ -0,0 +1,19 @@ +@file:Suppress("unused") + +package tasks.tests + +import org.gradle.api.tasks.CacheableTask +import org.gradle.api.tasks.TaskAction +import tasks.DockerCompose + +@CacheableTask +open class DockerComposeTest : DockerCompose() { + + @TaskAction + fun exec() { + setUp() + up("--abort-on-container-exit") // Wait for exit or timeout. + tearDown() + checkExitCodes(0L) // Check if any of the containers exited non-zero. + } +} \ No newline at end of file diff --git a/src/main/kotlin/tasks/tests/DockerContainerTest.kt b/src/main/kotlin/tasks/tests/DockerContainerTest.kt new file mode 100644 index 0000000..3deb8b6 --- /dev/null +++ b/src/main/kotlin/tasks/tests/DockerContainerTest.kt @@ -0,0 +1,17 @@ +package tasks.tests + +import org.gradle.api.tasks.CacheableTask +import org.gradle.api.tasks.TaskAction +import tasks.DockerContainer + +@CacheableTask +open class DockerContainerTest : DockerContainer() { + + @TaskAction + fun exec() { + setUp() + wait() // Wait for exit or timeout. + tearDown() + checkExitCode(0L) // Check if any of the containers exited non-zero. + } +} \ No newline at end of file diff --git a/src/main/kotlin/tasks/tests/ServiceStartsWithDefaultsTest.kt b/src/main/kotlin/tasks/tests/ServiceStartsWithDefaultsTest.kt new file mode 100644 index 0000000..b5e8b08 --- /dev/null +++ b/src/main/kotlin/tasks/tests/ServiceStartsWithDefaultsTest.kt @@ -0,0 +1,39 @@ +package tasks.tests + +import org.gradle.api.tasks.CacheableTask +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.TaskAction +import org.gradle.kotlin.dsl.property +import tasks.DockerContainer + +// Checks that it can bring up the image with default settings. +// Then waits for its services to start, after a set period if no errors occur it will stop the container and check the +// exit code. +@Suppress("UnstableApiUsage") +@CacheableTask +open class ServiceStartsWithDefaultsTest : DockerContainer() { + + // Maximum amount of time to wait for an error after services have successfully started (milliseconds). + @Input + val maxWaitForFailure = project.objects.property().convention(10000) + + @Input + val waitForMessage = project.objects.property().convention("[services.d] done.") + + @TaskAction + fun exec() { + setUp() + untilOutput { line -> + if (line.contains(waitForMessage.get())) { + logger.info("Services have successfully started") + // Services have started, wait for a fixed interval for the container to exited with an error. + Thread.sleep(maxWaitForFailure.get()) + false + } else { + true + } + } + tearDown() + checkExitCode(0L) // Check if any of the containers exited non-zero. + } +} \ No newline at end of file diff --git a/src/main/kotlin/utils/DockerCommandOptions.kt b/src/main/kotlin/utils/DockerCommandOptions.kt new file mode 100644 index 0000000..795a06c --- /dev/null +++ b/src/main/kotlin/utils/DockerCommandOptions.kt @@ -0,0 +1,65 @@ +package utils + +import org.gradle.api.file.RegularFile +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.ListProperty +import org.gradle.api.provider.MapProperty +import org.gradle.api.provider.Property +import org.gradle.api.provider.SetProperty +import kotlin.reflect.full.memberProperties + +// Helper functions to clean up argument processing for the various argument types. +@Suppress("UnstableApiUsage") +interface DockerCommandOptions { + // Annotation for serializing command line options to a string. + @Retention(AnnotationRetention.RUNTIME) + @Target(AnnotationTarget.PROPERTY) + annotation class Option(val option: String) + + fun toList(exclude: List = listOf()): List { + fun include(option: Option) = !exclude.contains(option.option) + + fun Property.toOption(option: Option) = + if (get() && include(option)) listOf(option.option) + else emptyList() + + fun Property.toOption(option: Option) = + if (isPresent && include(option)) listOf(option.option, get()) + else emptyList() + + fun RegularFileProperty.toOption(option: Option) = + if (isPresent && include(option)) listOf(option.option, get().asFile.absolutePath) + else emptyList() + + fun ListProperty.toOption(option: Option) = + if (include(option)) get().flatMap { listOf(option.option, it) } + else emptyList() + + fun MapProperty.toOption(option: Option) = + if (include(option)) get().flatMap { listOf(option.option, "${it.key}=${it.value}") } + else emptyList() + + fun SetProperty.toOption(option: Option) = + if (include(option)) get().flatMap { listOf(option.option, it) } + else emptyList() + + @Suppress("UNCHECKED_CAST") + return javaClass.kotlin.memberProperties.flatMap { member -> + member.annotations.filterIsInstance