diff --git a/build.gradle.kts b/build.gradle.kts index 2181000..04bf4ac 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -12,10 +12,6 @@ repositories { gradlePluginPortal() } -dependencies { - implementation("com.bmuschko:gradle-docker-plugin:6.7.0") -} - java { sourceCompatibility = JavaVersion.VERSION_1_8 targetCompatibility = JavaVersion.VERSION_1_8 @@ -48,4 +44,8 @@ publishing { } } } +} + +kotlinDslPluginOptions { + experimentalWarning.set(false) } \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index a4b4429..2a56324 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.2-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/src/main/kotlin/ca.islandora.gradle.docker.gradle.kts b/src/main/kotlin/ca.islandora.gradle.docker.gradle.kts index d1d6465..4f9d006 100644 --- a/src/main/kotlin/ca.islandora.gradle.docker.gradle.kts +++ b/src/main/kotlin/ca.islandora.gradle.docker.gradle.kts @@ -1,90 +1,396 @@ -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 +@file:Suppress("UnstableApiUsage") -plugins { - id("com.bmuschko.docker-remote-api") -} +import com.sun.xml.internal.messaging.saaj.util.ByteInputStream +import java.io.ByteArrayOutputStream +import org.gradle.api.model.ObjectFactory +import org.gradle.internal.impldep.org.apache.commons.lang.ObjectUtils +import java.io.BufferedOutputStream +import java.lang.RuntimeException +import javax.lang.model.type.NullType +import kotlin.reflect.full.memberProperties +import groovy.json.JsonSlurper +import org.gradle.internal.impldep.org.apache.http.protocol.HTTP +import org.gradle.nativeplatform.platform.internal.DefaultNativePlatform +import org.gradle.nativeplatform.platform.internal.DefaultOperatingSystem +import java.io.ByteArrayInputStream +import java.net.HttpURLConnection +import java.net.URL + +var os = DefaultNativePlatform.getCurrentOperatingSystem()!! +var arch = DefaultNativePlatform.getCurrentArchitecture()!! 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) +// 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") + +// Sources to search for images to use as caches when building. +val cacheFromRepositories by extra( + (properties.getOrDefault("docker.cacheFrom", "") as String) + .split(',') + .filter { it.isNotEmpty() } + .map { it.trim() } + .toSet() + .let { repositories -> + if (repositories.isEmpty()) { + setOf("islandora", if (isLocalRepository) localRepository else repository) + } else repositories + } +) + +// Repositories to push cache to (empty by default). +val cacheToRepositories by extra( + (properties.getOrDefault("docker.cacheTo", "") as String) + .split(',') + .map { it.trim() } + .filter { it.isNotEmpty() } + .toSet() + .toSet() + .let { repositories -> + if (repositories.isEmpty()) { + setOf(if (isLocalRepository) localRepository else repository) + } else repositories + } +) + +// 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") +val isKuberentesBuild by extra(buildDriver == "kubernetes") + +// The mode to use when populating the registry cache. +val cacheToMode by extra(properties.getOrDefault("docker.cacheToMode", if (isDockerBuild) "inline" else "max") as String) + +// Optionally disable the build cache as well as the remote cache. +val noBuildCache by extra((properties.getOrDefault("docker.noCache", false) as String).toBoolean()) + +// Platforms to built images to target. +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 + } +) + +// Computes a set of image tags for the given repository. +fun Project.imageTags(repository: String) = tags.map { "$repository/$name:$it" }.toSet() + +// Check if the project should have docker related tasks. +val Project.isDockerProject: Boolean + get() = projectDir.resolve("Dockerfile").exists() + +val installBinFmt by tasks.registering { + group = "isle-buildkit" + 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 = "isle-buildkit" + 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 = "isle-buildkit" + 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 + } + } + } +} + +val collectGarbageLocalRegistry by tasks.registering { + group = "isle-buildkit" + 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. +val updateHostsFile by tasks.registering { + group = "isle-buildkit" + 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) } -data class BindMount(val from: String?, val source: String?, val target: String) { - companion object { - private val EXTRACT_BIND_MOUNT_REGEX = """--mount=type=bind,([^\\]+)""".toRegex() +// 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 = "isle-buildkit" + 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") +} - 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"]!!) +val destroyBuilder by tasks.registering { + group = "isle-buildkit" + 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 + } } } +} - // 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") +val clean by tasks.registering { + group = "isle-buildkit" + description = "Destroy absolutely everything" + doLast { + exec { + commandLine = listOf("docker", "system", "prune", "-f") + isIgnoreExitValue = true + } } + dependsOn(destroyLocalRegistry, destroyBuilder) } -// 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) - .split(' ') - .filter { it.isNotEmpty() } - .map { "$image:$it" } - .toSet() - return if (tags.isEmpty()) setOf("$image:latest") else tags +// Often easier to just use the default builder. +val useDefaultBuilder by tasks.registering { + group = "isle-buildkit" + description = "Change the builder to use the Docker Daemon" + doLast { + project.exec { + commandLine = listOf("docker", "buildx", "use", "default") + } + } } -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() +val setupBuilder by tasks.registering { + group = "isle-buildkit" + 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 == "isle-buildkit") { + logging.captureStandardOutput(LogLevel.INFO) + logging.captureStandardError(LogLevel.INFO) } - result.exitValue == 0 -} catch (e: Exception) { - false } subprojects { @@ -93,202 +399,535 @@ subprojects { 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) + 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 + ) + + val loadTask by tasks.registering(DockerBuild::class) { + onlyIf { !isDockerBuild } + description = "Load docker image ('docker-container' or 'kubernetes' only)" + options.run { + load.set(true) // Makes the image available in `docker images`. + platforms.set(emptyList()) // Defaults to the host platform. + // Force the build args to use local repository if being used. + if (isLocalRepository) { + buildArgs.set(localRepositoryBuildArgs) } } - // 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() - } - } + mustRunAfter("delete") + } + + val build by tasks.registering(DockerBuild::class) { + description = "Build docker image" + 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) + } + // Import the image into `docker images`. + finalizedBy(loadTask) + mustRunAfter("delete") + } + } + + // We need to build before we can load, and we should always load after building. + loadTask.configure { + dependsOn(build) + } + + tasks.register("push") { + description = "Build and push docker image" + 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) } } - // 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] - FromInstruction(From(imagesTags("$repository/$name", project).first())) - } ?: instruction + mustRunAfter("delete") + } + + tasks.register("delete") { + description = "Delete image from local repository." + 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") + } } } - // 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) + dependsOn(getIpAddressOfLocalRegistry) + } + + // All build tasks have a number of shared defaults that can be overridden. + tasks.withType { + group = "isle-buildkit - ${project.name}" + // All tasks should limit output to info or greater. + logging.captureStandardOutput(LogLevel.INFO) + logging.captureStandardError(LogLevel.INFO) + // 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) } + } +} - tasks.register("push") { - images.set(buildDockerImage.map { it.images.get() }) - registryCredentials { - url.set(registryUrl) - username.set(registryUsername) - password.set(registryPassword) +//====================================================================================================================== +// Annotation for serializing command line options to a string. +@Retention(AnnotationRetention.RUNTIME) +@Target(AnnotationTarget.PROPERTY) +annotation class Option(val option: String) + +//====================================================================================================================== +// Helper functions to clean up argument processing for the various argument types. +interface DockerCommandOptions { + + fun toList(): List { + fun Property.toOption(option: Option) = + if (get()) listOf(option.option) + else emptyList() + + fun Property.toOption(option: Option) = + if (isPresent) listOf(option.option, get()) + else emptyList() + + fun RegularFileProperty.toOption(option: Option) = + if (isPresent) listOf(option.option, get().asFile.absolutePath) + else emptyList() + + fun ListProperty.toOption(option: Option) = + get().flatMap { listOf(option.option, it) } + + fun MapProperty.toOption(option: Option) = + get().flatMap { listOf(option.option, "${it.key}=${it.value}") } + + @Suppress("UNCHECKED_CAST") + return javaClass.kotlin.memberProperties.flatMap { member -> + member.annotations.filterIsInstance