blob: 0cde9b74dd1ec085ee5129135b7eaef3fc453210 [file] [log] [blame]
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import java.util.regex.Matcher
import java.util.regex.Pattern
import org.apache.commons.codec.digest.DigestUtils
description = 'Solr Docker image'
apply plugin: 'base'
// Solr Docker inputs
def dockerImageRepo = "${ -> propertyOrEnvOrDefault("solr.docker.imageRepo", "SOLR_DOCKER_IMAGE_REPO", "apache/solr") }"
def dockerImageTag = "${ -> propertyOrEnvOrDefault("solr.docker.imageTag", "SOLR_DOCKER_IMAGE_TAG", project.version) }"
def dockerImageName = "${ -> propertyOrEnvOrDefault("solr.docker.imageName", "SOLR_DOCKER_IMAGE_NAME", "${dockerImageRepo}:${dockerImageTag}") }"
def baseDockerImage = "${ -> propertyOrEnvOrDefault("solr.docker.baseImage", "SOLR_DOCKER_BASE_IMAGE", 'eclipse-temurin:17-jre-focal') }"
def officialDockerImageName = "${ -> propertyOrEnvOrDefault("solr.docker.imageName", "SOLR_DOCKER_IMAGE_NAME", "${dockerImageRepo}-official:${dockerImageTag}") }"
def releaseGpgFingerprint = "${ -> propertyOrDefault('signing.gnupg.keyName',propertyOrDefault('signing.keyId','')) }"
// Build directory locations
def imageIdFile = "$buildDir/image-id"
def smokeTestOfficial = "$buildDir/smoke-check-official"
def imageIdFileOfficial = "$smokeTestOfficial/image-id"
configurations {
packaging {
canBeResolved = true
}
packagingLocal {
canBeResolved = true
}
packagingOfficial {
canBeResolved = true
}
solrTgz {
canBeConsumed = false
canBeResolved = true
}
solrTgzSignature {
canBeConsumed = false
canBeResolved = true
}
dockerImage {
canBeResolved = true
}
dockerOfficialSmokeCheckImage {
canBeConsumed = false
canBeResolved = true
}
}
ext {
dockerfilesDirPath = "${buildDir}/dockerfiles"
packagingDir = file("${buildDir}/packaging")
}
dependencies {
packaging files(packagingDir) {
builtBy 'assemblePackaging'
}
packagingLocal files("${dockerfilesDirPath}/Dockerfile.local") {
builtBy 'createDockerfileLocal'
}
packagingOfficial files("${dockerfilesDirPath}/Dockerfile.official") {
builtBy 'createDockerfileOfficial'
}
solrTgz project(path: ":solr:packaging", configuration: "solrTgz")
solrTgzSignature project(path: ":solr:packaging", configuration: 'solrTgzSignature')
dockerImage files(imageIdFile) {
builtBy 'dockerBuild'
}
dockerOfficialSmokeCheckImage files(imageIdFileOfficial) {
builtBy 'testBuildDockerfileOfficial'
}
}
// We're using commons-codec for computing checksums.
buildscript {
repositories {
mavenCentral()
}
dependencies {
classpath "commons-codec:commons-codec:${scriptDepVersions['commons-codec']}"
}
}
def checksum = { file ->
return new DigestUtils(DigestUtils.sha512Digest).digestAsHex(file).trim()
}
task assemblePackaging(type: Sync) {
description = 'Assemble docker scripts and Dockerfile for Solr Packaging'
from(projectDir, {
include "scripts/**"
include "README.md"
})
from(dockerfilesDirPath, {
include 'Dockerfile.local'
rename {
'Dockerfile'
}
})
into packagingDir
}
task dockerBuild(dependsOn: configurations.solrTgz) {
group = 'Docker'
description = 'Build Solr docker image'
// Ensure that the docker image is rebuilt on build-arg changes or changes in the docker context
inputs.properties([
baseDockerImage: baseDockerImage
])
inputs.files(configurations.solrTgz)
doLast {
exec {
standardInput = configurations.solrTgz.singleFile.newDataInputStream()
commandLine "docker", "build",
"-f", "solr-${ -> project.version }/docker/Dockerfile",
"--iidfile", imageIdFile,
"--build-arg", "BASE_IMAGE=${ -> inputs.properties.baseDockerImage}",
"-"
}
}
// Print information on the image after it has been created
doLast {
def dockerImageId = file(imageIdFile).text
project.logger.lifecycle("Solr Docker Image Created")
project.logger.lifecycle("\tID: \t${ -> dockerImageId }")
project.logger.lifecycle("\tBase Image: \t${ -> baseDockerImage }")
project.logger.lifecycle("\tSolr Version: \t${ -> project.version }")
}
outputs.files(imageIdFile)
}
task dockerTag(dependsOn: tasks.dockerBuild) {
group = 'Docker'
description = 'Tag Solr docker image'
def dockerImageIdFile = file(imageIdFile)
// Ensure that the docker image is re-tagged if the image ID or desired tag changes
inputs.properties([
dockerImageName: dockerImageName,
])
inputs.file(dockerImageIdFile)
doLast {
def dockerImageId = dockerImageIdFile.text
exec {
commandLine "docker", "tag", dockerImageId, inputs.properties.dockerImageName
}
// Print information on the image after it has been created
project.logger.lifecycle("Solr Docker Image Tagged")
project.logger.lifecycle("\tID: \t$dockerImageId")
project.logger.lifecycle("\tTag: \t$dockerImageName")
}
}
task testDocker(type: TestDockerImageTask, dependsOn: tasks.dockerBuild) {
group = 'Docker'
description = 'Test Solr docker image built from the local Dockerfile'
idFile = tasks.dockerBuild.outputs.files.singleFile
outputDir = file("$buildDir/test-results")
// include/exclude options are designed for people who know their customizations will break some tests
testCasesInclude.value(Arrays.asList(propertyOrEnvOrDefault("solr.docker.tests.include", "SOLR_DOCKER_TESTS_INCLUDE", ",").split(",")))
testCasesExclude.value(Arrays.asList(propertyOrEnvOrDefault("solr.docker.tests.exclude", "SOLR_DOCKER_TESTS_EXCLUDE", ",").split(",")))
}
task dockerPush(dependsOn: tasks.dockerTag) {
group = 'Docker'
description = 'Push Solr docker image'
// Ensure that the docker image is re-pushed if the image ID or tag changes
inputs.properties([
dockerImageName: dockerImageName,
])
inputs.file(imageIdFile)
// We don't want to push a docker image unless the tests have passed
mustRunAfter tasks.testDocker
doLast {
exec {
commandLine "docker", "push", dockerImageName
}
// Print information on the image after it has been created
project.logger.lifecycle("Solr Docker Image Pushed: \t$dockerImageName")
}
}
// One task to build and tag a Solr docker image
task docker {
dependsOn tasks.dockerBuild, tasks.dockerTag
}
ext {
// Filters/Patterns for re-use in multiple "template" related tasks
commentFilter = { line ->
if (line.startsWith('#-#')) {
return null;
}
return line;
}
propReplacePattern = Pattern.compile('_REPLACE_((_|\\p{Upper})+)_')
}
task createBodySnippetDockerfile(type: Copy) {
from 'templates/Dockerfile.body.template'
into "$buildDir/snippets/"
rename { name -> name.replace("template","snippet") }
filteringCharset 'UTF-8'
// NOTE: The only "templating" the Dockerfile.body supports is removing comments.
//
// Any situation where it feel appropriate to add variable substitution should be reconsidered, and probably
// implemented as either a build-arg or as a variable expanded in the header snippets (or both)
filter( commentFilter )
}
ext {
// NOTE: 'props' are variables that will be replaced in the respective templates,
// and they must consist solely of characters matching the regex '(_|\\p{Upper})+'.
// They may only be used in ARG and FROM lines, via the syntax: '_REPLACE_FOO_'
// where 'FOO' is the key used in 'props' (NOTE the leading and trailing literal '_' characters)
dfLocalDetails = [
name: 'Local',
desc: 'Dockerfile used to create local Solr docker images directly from Solr release tgz file',
// NOTE: There should be no reason for Dockerfile.local to include unique values
//
// Values identical in both Dockerfiles should use consistent names in both templates and
// be defined in the task creation.
props: [:]
]
dfOfficialDetails = [
name: 'Official',
desc: 'Dockerfile used to create official Solr docker images published to hub.docker.io',
props: [
// NOTE: Only include values here that are distinct and unique to the Official Dockerfiles
//
// Values identical in both Dockerfiles should use consistent names in both templates and
// be defined in the task creation
// NOTE: SHA is lazy computed...
'SOLR_TGZ_SHA': "${ -> checksum(configurations.solrTgz.singleFile) }",
'RELEASE_MANAGER_GPG_FINGERPRINT': "${ -> releaseGpgFingerprint}"
]
]
}
/*
This section creates the following gradle tasks:
- createDockerfileOfficial
- createDockerfileLocal
Both will create a self-standing Dockerfile that can be used to build a Solr image.
These are templated using a unique header file for each Dockerfile and the same body template for the logic on installing Solr.
These templates can be found in the templates/ directory.
The snippets of each section (header and body) are saved to build/snippets after they are templated and before they are combined.
The final Dockerfiles are merely the snippet headers combined with the snippet body.
*/
[ dfLocalDetails, dfOfficialDetails ].each{ details ->
def fileName = "Dockerfile.${ -> details.name.toLowerCase(Locale.ROOT) }"
def outFile = file("$dockerfilesDirPath/${fileName}")
tasks.create("createDockerfile${details.name}", Copy) {
description "Creates ${details.desc}"
def props = [
// Values defined here should be common (and consistent) across both Dockerfiles
'BASE_IMAGE': baseDockerImage,
'SOLR_VERSION': "${ -> project.version}",
* : details.props
]
dependsOn tasks.createBodySnippetDockerfile
inputs.properties(props)
inputs.file("$buildDir/snippets/Dockerfile.body.snippet")
outputs.file(outFile)
from "templates/${fileName}.header.template"
into "$buildDir/snippets/"
rename { name -> name.replace("template","snippet") }
filteringCharset 'UTF-8'
filter( commentFilter )
filter( { line ->
if ( line.startsWith("FROM ") || line.startsWith("ARG ") ) {
Matcher matcher = project.ext.propReplacePattern.matcher(line);
StringBuilder sb = new StringBuilder();
if (matcher.find()) {
String key = matcher.group(1);
if (null == key || key.isEmpty() || ( ! props.containsKey(key) ) ) {
throw new GradleException("Line contains invalid REPLACE variable (" + key + "): " + line);
}
matcher.appendReplacement(sb, props.get(key) );
}
matcher.appendTail(sb);
return sb.toString();
}
return line;
})
doLast {
outFile.withWriter('UTF-8') { writer ->
files("$buildDir/snippets/${fileName}.header.snippet",
"$buildDir/snippets/Dockerfile.body.snippet").each { snippet ->
snippet.withReader('UTF-8') { reader ->
writer << reader
}
}
}
}
}
}
assemblePackaging.dependsOn tasks.createDockerfileLocal
tasks.createDockerfileOfficial.dependsOn configurations.solrTgz // to lazy compute SHA
// sanity check...
if (''.equals(releaseGpgFingerprint)) {
gradle.taskGraph.whenReady { graph ->
if ( graph.hasTask(createDockerfileOfficial) ) {
throw new GradleException("No GPG keyName found, please see help/publishing.txt (GPG key is neccessary to create Dockerfile.official)")
}
}
}
task testBuildDockerfileOfficial(type: Copy) {
description = 'Test "docker build" works with our generated Dockerfile.official using Mocked URLs'
dependsOn createDockerfileOfficial
dependsOn configurations.solrTgz
dependsOn configurations.solrTgzSignature
def mockHttpdHome = file("$smokeTestOfficial/mock-httpd-home");
inputs.file("$dockerfilesDirPath/Dockerfile.official")
outputs.dir(mockHttpdHome)
outputs.file(imageIdFileOfficial)
from configurations.solrTgzSignature
from configurations.solrTgz
into mockHttpdHome
doLast {
// A file to record the container ID of our mock httpd
def mockServerIdFile = file("${buildDir}/dockerfile-mock-artifact-server-cid.txt")
// if we encounter any problems running our test, we'll fill this in and use it to suppress any
// other exceptions we encounter on cleanup...
def mainException = null;
// TODO: setup a little 'suppressOrThrow(Exception)' closure for reuse below....
try {
// run an httpd server to host our artifacts
logger.lifecycle('Running mock HTTPD server our testing...');
exec {
commandLine 'docker', 'run',
'--cidfile', mockServerIdFile,
'--rm',
'-d',
'-v', "${mockHttpdHome.absoluteFile}:/data",
'-w', '/data',
'python:3-alpine', 'python', '-m', 'http.server', '9876'
}
try {
def mockServerId = mockServerIdFile.text
def mockServerIpStdOut = new ByteArrayOutputStream()
exec{
commandLine 'docker', 'inspect', "--format={{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}", mockServerId
standardOutput = mockServerIpStdOut
}
def mockServerIp = mockServerIpStdOut.toString().trim()
// *NOW* we can actually run our docker build command...
logger.lifecycle('Running docker build on Dockerfile.official...');
exec {
standardInput = file("${dockerfilesDirPath}/Dockerfile.official").newDataInputStream()
commandLine 'docker', 'build',
'--add-host', "mock-solr-dl-server:${mockServerIp}",
'--no-cache', // force fresh downloads from our current network
"--iidfile", imageIdFileOfficial,
'--build-arg', "SOLR_CLOSER_URL=http://mock-solr-dl-server:9876/solr-${ -> project.version}.tgz",
'--build-arg', "SOLR_ARCHIVE_URL=http://mock-solr-dl-server:9876/solr-${ -> project.version}.tgz",
'--tag', officialDockerImageName,
'-'
}
def officialDockerImageId = file(imageIdFileOfficial).text
// Print information on the image after it has been created
project.logger.lifecycle("\"Official\" Solr Docker Image Tagged")
project.logger.lifecycle("\tID: \t$officialDockerImageId")
project.logger.lifecycle("\tTag: \t$officialDockerImageName")
} finally {
// Try to shut down our mock httpd server....
if (mockServerIdFile.exists()) {
def mockServerId = mockServerIdFile.text
try {
exec { commandLine 'docker', 'stop', mockServerId }
} catch (Exception e) {
logger.error("Unable to stop docker container ${mockServerId}", e)
if (null != mainException) {
mainException.addSuppressed(e);
} else {
mainException = e;
throw e;
}
} finally {
project.delete(mockServerIdFile)
}
}
}
} catch (Exception e) {
mainException = e
throw e;
}
}
}
task testDockerfileOfficial(type: TestDockerImageTask, dependsOn: configurations.dockerOfficialSmokeCheckImage) {
description = 'Smoke Test Solr docker image built from the official Dockerfile'
idFile = file(imageIdFileOfficial)
outputDir = file("$smokeTestOfficial/test-results")
// This test does not respect the include/exclude properties that `testDocker` does.
// All docker tests will be run, no matter the properties specified.
testCasesInclude.empty()
testCasesExclude.empty()
}
// Re-usable class for running tests...
abstract class TestDockerImageTask extends DefaultTask {
// Ensure that the docker image is re-tested if the image ID changes or the test files change
@InputFile
abstract public RegularFileProperty getIdFile()
// Ensure that the docker image is re-tested if the shared test library changes
@InputFile
File sharedTestLibraryFile = project.file("tests/shared.sh")
@InputDirectory
File sourceDir = project.file("tests/cases")
@Input
SetProperty<String> testCasesInclude = project.objects.setProperty(String)
@Input
SetProperty<String> testCasesExclude = project.objects.setProperty(String)
@OutputDirectory
abstract public DirectoryProperty getOutputDir()
@Inject
abstract public WorkerExecutor getWorkerExecutor();
public static interface SingleTestParameters extends WorkParameters {
// NOTE: we explicitly don't use DirectoryProperty here because the way WorkerExecutor serializes the params
// causes weird "wrapped" objects to come back that don't work when you try to call `.getAsFile()` or `.getAsFile().getPath()`
Property<String> getTestName();
Property<String> getTestDir();
Property<String> getWorkDir();
Property<String> getImageId();
}
public abstract static class SingleTestAction implements WorkAction<SingleTestParameters> {
@Inject
abstract public ExecOperations getExec();
@Override
public void execute() {
def testCaseName = getParameters().getTestName().get()
def testCaseDir = getParameters().getTestDir().get()
def testCaseWorkDir = getParameters().getWorkDir().get()
def testCaseBuildDir = "${testCaseWorkDir}/build_dir"
System.out.println("Starting Solr Docker test: ${testCaseName}")
def res = getExec().exec {
// we'll handle it ourselves so we can report the details of which test failed
ignoreExitValue true
environment "DEBUG", "true"
environment "TEST_DIR", testCaseDir
environment "BUILD_DIR", testCaseBuildDir
standardOutput new FileOutputStream("${testCaseWorkDir}/test.std.out")
errorOutput new FileOutputStream("${testCaseWorkDir}/test.err.out")
commandLine "bash", "${testCaseDir}/test.sh", getParameters().getImageId().get()
}
def ev = res.getExitValue()
if (0 != ev) {
throw new GradleException("Docker test failure=${ev}: Test: ${testCaseName} Output: ${testCaseWorkDir}");
} else {
System.out.println("Completed Solr Docker test: ${testCaseName}")
}
}
}
@TaskAction
public void runTests() {
// no easy way to control the amount of parallelization of the work queue (independent of `org.gradle.workers.max`)
// so the best we can do (simply) is a boolean setting that controls if we `await()` after each `submit()`
def workQueue = getWorkerExecutor().noIsolation();
def runConcurrentTests = project.propertyOrEnvOrDefault("solr.docker.tests.concurrent", "SOLR_DOCKER_TESTS_CONCURRENT", "false").toBoolean()
def imageId = idFile.getAsFile().get().text
// Print information on the image before it is tested
logger.lifecycle("Testing Solr Image ID: $imageId ${ -> runConcurrentTests ? '(concurrently)' : '(sequentially)'}")
def includes = testCasesInclude.get()
def excludes = testCasesExclude.get()
// "Run" each of the test cases
sourceDir.eachFile { file ->
def testName = file.getName()
def outDir = outputDir.get()
def testCaseWorkDir = outDir.dir(testName)
// If specific tests are specified, only run those. Otherwise run all that are not ignored.
def runTest = !includes.isEmpty() ? includes.contains(testName) : !excludes.contains(testName)
if (runTest) {
project.mkdir testCaseWorkDir
def paramSetup = { params ->
params.getTestName().set(testName)
params.getTestDir().set(file.getPath())
params.getWorkDir().set(testCaseWorkDir.getAsFile().getPath())
params.getImageId().set(imageId)
}
workQueue.submit(SingleTestAction.class, paramSetup);
if (! runConcurrentTests) {
workQueue.await();
}
}
}
}
}