You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@solr.apache.org by ho...@apache.org on 2021/05/17 21:18:02 UTC

[solr] branch main updated: SOLR-15335: Add support for Official Dockerfile generation

This is an automated email from the ASF dual-hosted git repository.

houston pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/solr.git


The following commit(s) were added to refs/heads/main by this push:
     new a1bdadc  SOLR-15335: Add support for Official Dockerfile generation
a1bdadc is described below

commit a1bdadc63a69f59f68276fe2eda0a2586bcffd11
Author: Houston Putman <ho...@apache.org>
AuthorDate: Mon May 17 15:58:35 2021 -0500

    SOLR-15335: Add support for Official Dockerfile generation
    
    Co-authored-by: Chris Hostetter <ho...@apache.org>
---
 gradle/help.gradle                                 |   1 +
 solr/CHANGES.txt                                   |   4 +-
 solr/docker/build.gradle                           | 382 +++++++++++++++++----
 solr/docker/gradle-help.txt                        |  27 ++
 .../Dockerfile.body.template}                      |  43 +--
 .../templates/Dockerfile.local.header.template     |  29 ++
 .../templates/Dockerfile.official.header.template  |  79 +++++
 solr/packaging/build.gradle                        |   6 +
 8 files changed, 481 insertions(+), 90 deletions(-)

diff --git a/gradle/help.gradle b/gradle/help.gradle
index 5fcec75..7bbdaf5 100644
--- a/gradle/help.gradle
+++ b/gradle/help.gradle
@@ -30,6 +30,7 @@ configure(rootProject) {
       ["Git", "help/git.txt", "Git assistance and guides."],
       ["ValidateLogCalls", "help/validateLogCalls.txt", "How to use logging calls efficiently."],
       ["IDEs", "help/IDEs.txt", "IDE support."],
+      ["GpgSigning", "help/gpgSigning.txt", "Signing artifacts with GPG."],
       ["Docker", "solr/docker/gradle-help.txt", "Building Solr Docker images."],
   ]
 
diff --git a/solr/CHANGES.txt b/solr/CHANGES.txt
index f5b8e6b..2a540bd 100644
--- a/solr/CHANGES.txt
+++ b/solr/CHANGES.txt
@@ -125,7 +125,9 @@ when told to. The admin UI now tells it to. (Nazerke Seidan, David Smiley)
 
 * SOLR-15340: Rename shardsWhitelist and extract AllowListUrlChecker to use it more broadly. (Bruno Roustant)
 
-* SOLR-14790: Move Solr Docker image documentation to the ref guide. (Houston Putman)
+* SOLR-14790: Docker: Move Solr Docker image documentation to the ref guide. (Houston Putman)
+
+* SOLR-15335: Docker: Solr has capability to build functionally-identical local and official Docker image. (hossman, Houston Putman)
 
 Other Changes
 ----------------------
diff --git a/solr/docker/build.gradle b/solr/docker/build.gradle
index 4fd9117..c9320e0 100644
--- a/solr/docker/build.gradle
+++ b/solr/docker/build.gradle
@@ -15,8 +15,9 @@
  * limitations under the License.
  */
 
-import com.google.common.base.Preconditions
-import com.google.common.base.Strings
+import java.util.regex.Matcher
+import java.util.regex.Pattern
+import org.apache.commons.codec.digest.DigestUtils
 
 description = 'Solr Docker image'
 
@@ -29,28 +30,39 @@ def dockerImageName = propertyOrEnvOrDefault("solr.docker.imageName", "SOLR_DOCK
 def baseDockerImage = propertyOrEnvOrDefault("solr.docker.baseImage", "SOLR_DOCKER_BASE_IMAGE", 'openjdk:11-jre-slim')
 def githubUrlOrMirror = propertyOrEnvOrDefault("solr.docker.githubUrl", "SOLR_DOCKER_GITHUB_URL", 'github.com')
 
+def releaseGpgFingerprint = propertyOrDefault('signing.gnupg.keyName','');
+
+def testCasesDir = "tests/cases"
+
 // Build directory locations
 def imageIdFile = "$buildDir/image-id"
 
+def smokeTestOfficial = "$buildDir/smoke-check-official"
+def imageIdFileOfficial = "$smokeTestOfficial/image-id"
+
 configurations {
   packaging {
     canBeResolved = true
   }
-  solrArtifacts {
+  solrTgz {
+    canBeConsumed = false
     canBeResolved = true
   }
-  dockerContext {
+  solrTgzSignature {
+    canBeConsumed = false
     canBeResolved = true
   }
   dockerImage {
     canBeResolved = true
   }
+  dockerOfficialSmokeCheckImage {
+    canBeConsumed = false
+    canBeResolved = true
+  }
 }
 
 ext {
   packagingDir = file("${buildDir}/packaging")
-  dockerContextFile = "context.tgz"
-  dockerContextPath = "${buildDir}/${dockerContextFile}"
 }
 
 dependencies {
@@ -58,40 +70,44 @@ dependencies {
     builtBy 'assemblePackaging'
   }
 
-  solrArtifacts project(path: ":solr:packaging", configuration: 'archives')
-
-  dockerContext files(dockerContextPath) {
-    builtBy 'dockerContext'
-  }
+  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 "Dockerfile"
     include "scripts/**"
   })
-
-  into packagingDir
-}
-
-task dockerContext(type: Sync) {
-  description = 'Sync Solr tgz to become docker context'
-
-  from(configurations.solrArtifacts, {
-    include "*.tgz"
-    rename { file -> dockerContextFile }
+  from(buildDir, {
+    include 'Dockerfile.local'
   })
-
-  into buildDir
+  into packagingDir
 }
 
-task dockerBuild(dependsOn: configurations.dockerContext) {
+task dockerBuild(dependsOn: configurations.solrTgz) {
   group = 'Docker'
   description = 'Build Solr docker image'
 
@@ -100,13 +116,13 @@ task dockerBuild(dependsOn: configurations.dockerContext) {
           baseDockerImage: baseDockerImage,
           githubUrlOrMirror: githubUrlOrMirror
   ])
-  inputs.files(dockerContextPath)
+  inputs.files(configurations.solrTgz)
 
   doLast {
     exec {
-      standardInput = configurations.dockerContext.singleFile.newDataInputStream()
+      standardInput = configurations.solrTgz.singleFile.newDataInputStream()
       commandLine "docker", "build",
-              "-f", "solr-${version}/docker/Dockerfile",
+              "-f", "solr-${version}/docker/Dockerfile.local",
               "--iidfile", imageIdFile,
               "--build-arg", "BASE_IMAGE=${inputs.properties.baseDockerImage}",
               "--build-arg", "GITHUB_URL=${inputs.properties.githubUrlOrMirror}",
@@ -151,48 +167,54 @@ task dockerTag(dependsOn: tasks.dockerBuild) {
   }
 }
 
+
+      
+// Re-usable closure to run tests...
+def testDockerImage = { solrImageId, outputDir, testCasesInclude, testCasesExclude ->
+  // Print information on the image before it is tested
+  logger.lifecycle("Testing Solr Image:")
+  logger.lifecycle("\tID: $solrImageId\n")
+  
+  // Run the tests
+  def sourceDir = file(testCasesDir)
+  sourceDir.eachFile  { file ->
+    def testName = file.getName()
+    def testCaseBuildDir = "${outputDir}/${testName}"
+    
+    // If specific tests are specified, only run those. Otherwise run all that are not ignored.
+    def runTest = !testCasesInclude.isEmpty() ? testCasesInclude.contains(testName) : !testCasesExclude.contains(testName)
+    if (runTest) {
+      exec {
+        environment "TEST_DIR", file
+        environment "BUILD_DIR", testCaseBuildDir
+        commandLine "bash", "$file/test.sh", solrImageId
+      }
+    }
+  }
+}
+
 task testDocker(dependsOn: tasks.dockerBuild) {
   group = 'Docker'
-  description = 'Test Solr docker image'
+  description = 'Test Solr docker image built from Dockerfile.local'
 
-  def inputDir = "tests/cases"
-  def outputDir = "$buildDir/tmp/tests"
+  def iidFile = tasks.dockerBuild.outputs.files.singleFile
 
   // Ensure that the docker image is re-tested if the image ID changes or the test files change
+  inputs.file(iidFile)
+  inputs.dir(testCasesDir)
   inputs.properties([
-          includeTests: new HashSet(Arrays.asList(propertyOrEnvOrDefault("solr.docker.tests.include", "SOLR_DOCKER_TESTS_INCLUDE", ",").split(","))),
-          excludeTests: new HashSet(Arrays.asList(propertyOrEnvOrDefault("solr.docker.tests.exclude", "SOLR_DOCKER_TESTS_EXCLUDE", ",").split(",")))
+    // include/exclude options are designed for people who know their customizations will break some tests
+    includeTests: new HashSet(Arrays.asList(propertyOrEnvOrDefault("solr.docker.tests.include", "SOLR_DOCKER_TESTS_INCLUDE", ",").split(","))),
+    excludeTests: new HashSet(Arrays.asList(propertyOrEnvOrDefault("solr.docker.tests.exclude", "SOLR_DOCKER_TESTS_EXCLUDE", ",").split(",")))
   ])
-  inputs.file(imageIdFile)
-  inputs.dir(inputDir)
+  
+  def outputDir = "$buildDir/test-results"
+  outputs.dir(outputDir)
 
   doLast {
-    def solrImageId = tasks.dockerBuild.outputs.files.singleFile.text
-    def solrImageName = solrImageId.substring(7, 14)
-
-    // Print information on the image before it is tested
-    logger.lifecycle("Testing Solr Image:")
-    logger.lifecycle("\tID: $solrImageId\n")
-
-    // Run the tests
-    def sourceDir = file(inputDir)
-    sourceDir.eachFile  { file ->
-      def testName = file.getName()
-      def testCaseBuildDir = "${outputDir}/${testName}"
-
-      // If specific tests are specified, only run those. Otherwise run all that are not ignored.
-      def runTest = !inputs.properties.includeTests.isEmpty() ? inputs.properties.includeTests.contains(testName) : !inputs.properties.excludeTests.contains(testName)
-      if (runTest) {
-        exec {
-          environment "TEST_DIR", file
-          environment "BUILD_DIR", testCaseBuildDir
-          commandLine "bash", "$file/test.sh", solrImageName
-        }
-      }
-    }
+    def solrImageId = iidFile.text
+    testDockerImage(solrImageId, outputDir, inputs.properties.includeTests, inputs.properties.excludeTests)
   }
-
-  outputs.dir(outputDir)
 }
 
 task dockerPush(dependsOn: tasks.dockerTag) {
@@ -222,3 +244,243 @@ task dockerPush(dependsOn: tasks.dockerTag) {
 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
+      
+      'SOLR_VERSION': version,
+      // 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("$buildDir/${fileName}")
+  
+  def props = [
+    // Values defined here should be common (and consistent) across both Dockerfiles
+    'BASE_IMAGE': baseDockerImage,
+    * : details.props
+  ]
+  tasks.create("createDockerfile${details.name}", Copy) {
+    description "Creates ${details.desc}"
+    
+    dependsOn tasks.createBodySnippetDockerfile
+    inputs.properties(props)
+    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/gpgSigning.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("$buildDir/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("${buildDir}/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-${version}.tgz",
+            '--build-arg', "SOLR_ARCHIVE_URL=http://mock-solr-dl-server:9876/solr-${version}.tgz",
+            "--build-arg", "GITHUB_URL=${githubUrlOrMirror}",
+            '-'
+        }
+        
+      } 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(dependsOn: configurations.dockerOfficialSmokeCheckImage) {
+  description = 'Smoke Test Solr docker image built from Dockerfile.official'
+  
+  def iidFile = file(imageIdFileOfficial)
+  
+  // Ensure that the docker image is re-tested if the image ID changes or the test files change
+  inputs.file(iidFile)
+  inputs.dir(testCasesDir)
+
+  // This test does not respect the inputs that `testDocker` does.
+  // All docker tests will be run, no matter the inputs given.
+  
+  def outputDir = "$smokeTestOfficial/test-results"
+  outputs.dir(outputDir)
+
+  doLast {
+    def solrImageId = iidFile.text
+    // for smoke testing Dockerfile.official, we always run all tests.
+    // (if there is a test we don't expect to pass, we should delete it)
+    testDockerImage(solrImageId, outputDir, [] as Set, [] as Set)
+  }
+}
diff --git a/solr/docker/gradle-help.txt b/solr/docker/gradle-help.txt
index 63ad735..8593a69 100644
--- a/solr/docker/gradle-help.txt
+++ b/solr/docker/gradle-help.txt
@@ -78,3 +78,30 @@ Run specific tests:
 Exclude specific tests:
    EnvVar: SOLR_DOCKER_TESTS_EXCLUDE
    Gradle Property: -Psolr.docker.tests.exclude
+
+-------
+The Official Solr Image
+-------
+
+The Official Solr Docker Image is also generated within this module.
+This section should only be used by developers testing that their changes to the Solr project are compatible with the Official image.
+All users should build custom images using the instructions above.
+
+NOTE: All gradle commands for the Official Dockerfile below require the Solr artifacts to be signed with a GPG Key.
+For necessary inputs and properties, please refer to:
+
+gradlew helpGpgSigning
+
+You can use the following command to build an official Solr Dockerfile.
+The Dockerfile will be created at: solr/docker/build/Dockerfile.official
+
+gradlew createDockerfileOfficial
+
+You can also test the official docker image using the following command.
+This will build the official Dockerfile, create a local server to host the local Solr artifacts, and build the Official Solr image using this local server.
+
+gradlew testBuildDockerfileOfficial
+
+You can also run the official Docker image built by the command above through all Solr Docker tests with the following:
+
+gradlew testDockerfileOfficial
diff --git a/solr/docker/Dockerfile b/solr/docker/templates/Dockerfile.body.template
similarity index 64%
rename from solr/docker/Dockerfile
rename to solr/docker/templates/Dockerfile.body.template
index 443a0eb..5b55990 100644
--- a/solr/docker/Dockerfile
+++ b/solr/docker/templates/Dockerfile.body.template
@@ -1,41 +1,28 @@
-# This file can be used to build an (unofficial) Docker image of Apache Solr.
-#
-# The primary purpose of this file, is for use by Solr developers, with a java/gradle development env, who
-# wish to build customized -- or "patched" docker images of Solr.  For this type of usecase, this file
-# will be used automatically by gradle to build docker images from your local src.
-#   Example:
-#     ./gradlew dockerBuild
-#
-# For most Solr users, using this Dockerfile is not recommended: pre-built docker images of Solr are 
-# available at https://hub.docker.com/_/solr -- however this file can be used to build docker images from
-# a Solr release artifact -- either from a remote TGZ file, or from an TGZ artifact you have downloaded
-# locally.
-#    Example:
-#      docker build -f solr-X.Y.Z/docker/Dockerfile https://www.apache.org/dyn/closer.lua/solr/X.Y.Z/solr-X.Y.Z.tgz
-#    Example:
-#      docker build -f solr-X.Y.Z/docker/Dockerfile - < solr-X.Y.Z.tgz
-
-
-ARG BASE_IMAGE=openjdk:11-jre-slim
-
-FROM $BASE_IMAGE as input
-
-COPY / /opt/
+#-#
+#-# This template is used as the primary body of both "local" and "official" Apache Solr Dockerfiles.
+#-# It contains everything that should be "common" between both files.
+#-#
+#-# ! ! ! NO VARIABLES OR CONDITIONAL LOGIC SHOULD BE NEEDED OR USED IN THIS TEMPLATE ! ! !
+#-# (It exists as a 'template' solely so that this comment can exist)
+#-#
+#-# The pre-reqs for this file (which must be satisfied for any "header" pre-pended to it are that 
+#-# '/opt/solr-X.Y.Z' exists (ie: COPY'ed from the build context and/or a downloaded and unpacked solr.tgz)
+#-#
+#-#
+#-#
 
 # remove what we don't want; ensure permissions are right
 #  TODO; arguably these permissions should have been set correctly previously in the TAR
 RUN set -ex; \
   (cd /opt; ln -s solr-*/ solr); \
-  rm -Rf /opt/solr/docs /opt/solr/docker/Dockerfile /opt/solr/dist/{solr-solrj-*.jar,solrj-lib,solr-test-framework-*.jar,test-framework}; \
+  rm -Rf /opt/solr/docs /opt/solr/docker/Dockerfile* /opt/solr/dist/{solr-solrj-*.jar,solrj-lib,solr-test-framework-*.jar,test-framework}; \
   find /opt/solr/ -type d -print0 | xargs -0 chmod 0755; \
   find /opt/solr/ -type f -print0 | xargs -0 chmod 0644; \
   chmod -R 0755 /opt/solr/docker/scripts /opt/solr/bin /opt/solr/contrib/prometheus-exporter/bin/solr-exporter /opt/solr/server/scripts/cloud-scripts
 
-FROM $BASE_IMAGE
-
 LABEL maintainer="The Apache Lucene/Solr Project"
 LABEL repository="https://github.com/apache/lucene-solr"
-
+  
 # Override the default github URL to provide a mirror for github releases.
 ARG GITHUB_URL=github.com
 
@@ -63,8 +50,6 @@ RUN set -ex; \
   groupadd -r --gid "$SOLR_GID" "$SOLR_GROUP"; \
   useradd -r --uid "$SOLR_UID" --gid "$SOLR_GID" "$SOLR_USER"
 
-COPY --from=input /opt/ /opt/
-
 RUN set -ex; \
   mkdir -p /opt/solr/server/solr/lib /docker-entrypoint-initdb.d; \
   cp /opt/solr/bin/solr.in.sh /etc/default/solr.in.sh; \
diff --git a/solr/docker/templates/Dockerfile.local.header.template b/solr/docker/templates/Dockerfile.local.header.template
new file mode 100644
index 0000000..a9173d6
--- /dev/null
+++ b/solr/docker/templates/Dockerfile.local.header.template
@@ -0,0 +1,29 @@
+#-#
+#-# This template is used as the header of "local" Apache Solr Dockerfiles.
+#-#
+#-# #######################################################################
+#-#
+# This file can be used to build an (unofficial) Docker image of Apache Solr.
+#
+# The primary purpose of this file, is for use by Solr developers, with a java/gradle development env, who
+# wish to build customized -- or "patched" docker images of Solr.  For this type of usecase, this file
+# will be used automatically by gradle to build docker images from your local src.
+#   Example:
+#     ./gradlew dockerBuild
+#
+# For most Solr users, using this Dockerfile is not recommended: pre-built docker images of Solr are 
+# available at https://hub.docker.com/_/solr -- however this file can be used to build docker images from
+# a Solr release artifact -- either from a remote TGZ file, or from an TGZ artifact you have downloaded
+# locally.
+#    Example:
+#      docker build -f solr-X.Y.Z/docker/Dockerfile https://www.apache.org/dyn/closer.lua/solr/X.Y.Z/solr-X.Y.Z.tgz
+#    Example:
+#      docker build -f solr-X.Y.Z/docker/Dockerfile - < solr-X.Y.Z.tgz
+
+
+ARG BASE_IMAGE=_REPLACE_BASE_IMAGE_
+
+FROM $BASE_IMAGE
+
+COPY / /opt/
+
diff --git a/solr/docker/templates/Dockerfile.official.header.template b/solr/docker/templates/Dockerfile.official.header.template
new file mode 100644
index 0000000..a342547
--- /dev/null
+++ b/solr/docker/templates/Dockerfile.official.header.template
@@ -0,0 +1,79 @@
+#-#
+#-# This template is used as the header of "official" Apache Solr Dockerfiles.
+#-#
+#-# #######################################################################
+#-#
+
+FROM _REPLACE_BASE_IMAGE_
+
+# TODO: remove things that exist solely for downstream specialization since Dockerfile.local now exists for that
+# TODO: replace 3rd party keyservers with official Apache Solr KEYS url
+
+ARG SOLR_VERSION="_REPLACE_SOLR_VERSION_"
+ARG SOLR_SHA512="_REPLACE_SOLR_TGZ_SHA_"
+ARG SOLR_KEYS="_REPLACE_RELEASE_MANAGER_GPG_FINGERPRINT_"
+
+# If specified, this will override SOLR_DOWNLOAD_SERVER and all ASF mirrors. Typically used downstream for custom builds
+ARG SOLR_DOWNLOAD_URL
+# TODO: That comment isn't strictly true, if SOLR_DOWNLOAD_URL fails, other mirrors will be attempted
+# TODO: see patch in SOLR-15250 for some example ideas on fixing this to be more strict
+
+# Override the default solr download location with a prefered mirror, e.g.:
+#   docker build -t mine --build-arg SOLR_DOWNLOAD_SERVER=http://www-eu.apache.org/dist/lucene/solr .
+ARG SOLR_DOWNLOAD_SERVER
+
+# These should never be overridden except for the purposes of testing the Dockerfile before release
+ARG SOLR_CLOSER_URL="http://www.apache.org/dyn/closer.lua?filename=solr/$SOLR_VERSION/solr-$SOLR_VERSION.tgz&action=download"
+ARG SOLR_DIST_URL="https://www.apache.org/dist/lucene/solr/$SOLR_VERSION/solr-$SOLR_VERSION.tgz"
+ARG SOLR_ARCHIVE_URL="https://archive.apache.org/dist/lucene/solr/$SOLR_VERSION/solr-$SOLR_VERSION.tgz"
+
+RUN set -ex; \
+  apt-get update; \
+  apt-get -y install wget gpg; \
+  rm -rf /var/lib/apt/lists/*; \
+  export GNUPGHOME="/tmp/gnupg_home"; \
+  mkdir -p "$GNUPGHOME"; \
+  chmod 700 "$GNUPGHOME"; \
+  echo "disable-ipv6" >> "$GNUPGHOME/dirmngr.conf"; \
+  for key in $SOLR_KEYS; do \
+    found=''; \
+    for server in \
+      ha.pool.sks-keyservers.net \
+      hkp://keyserver.ubuntu.com:80 \
+      hkp://p80.pool.sks-keyservers.net:80 \
+      pgp.mit.edu \
+    ; do \
+      echo "  trying $server for $key"; \
+      gpg --batch --keyserver "$server" --keyserver-options timeout=10 --recv-keys "$key" && found=yes && break; \
+      gpg --batch --keyserver "$server" --keyserver-options timeout=10 --recv-keys "$key" && found=yes && break; \
+    done; \
+    test -z "$found" && echo >&2 "error: failed to fetch $key from several disparate servers -- network issues?" && exit 1; \
+  done; \
+  MAX_REDIRECTS=1; \
+  if [ -n "$SOLR_DOWNLOAD_URL" ]; then \
+    # If a custom URL is defined, we download from non-ASF mirror URL and allow more redirects and skip GPG step
+    # This takes effect only if the SOLR_DOWNLOAD_URL build-arg is specified, typically in downstream Dockerfiles
+    MAX_REDIRECTS=4; \
+    SKIP_GPG_CHECK=true; \
+  elif [ -n "$SOLR_DOWNLOAD_SERVER" ]; then \
+    SOLR_DOWNLOAD_URL="$SOLR_DOWNLOAD_SERVER/$SOLR_VERSION/solr-$SOLR_VERSION.tgz"; \
+  fi; \
+  for url in $SOLR_DOWNLOAD_URL $SOLR_CLOSER_URL $SOLR_DIST_URL $SOLR_ARCHIVE_URL; do \
+    if [ -f "/opt/solr-$SOLR_VERSION.tgz" ]; then break; fi; \
+    echo "downloading $url"; \
+    if wget -t 10 --max-redirect $MAX_REDIRECTS --retry-connrefused -nv "$url" -O "/opt/solr-$SOLR_VERSION.tgz"; then break; else rm -f "/opt/solr-$SOLR_VERSION.tgz"; fi; \
+  done; \
+  if [ ! -f "/opt/solr-$SOLR_VERSION.tgz" ]; then echo "failed all download attempts for solr-$SOLR_VERSION.tgz"; exit 1; fi; \
+  if [ -z "$SKIP_GPG_CHECK" ]; then \
+    echo "downloading $SOLR_ARCHIVE_URL.asc"; \
+    wget -nv "$SOLR_ARCHIVE_URL.asc" -O "/opt/solr-$SOLR_VERSION.tgz.asc"; \
+    echo "$SOLR_SHA512 */opt/solr-$SOLR_VERSION.tgz" | sha512sum -c -; \
+    (>&2 ls -l "/opt/solr-$SOLR_VERSION.tgz" "/opt/solr-$SOLR_VERSION.tgz.asc"); \
+    gpg --batch --verify "/opt/solr-$SOLR_VERSION.tgz.asc" "/opt/solr-$SOLR_VERSION.tgz"; \
+  else \
+    echo "Skipping GPG validation due to non-Apache build"; \
+  fi; \
+  { command -v gpgconf; gpgconf --kill all || :; }; \
+  rm -r "$GNUPGHOME"; \
+  tar -C /opt --extract --file "/opt/solr-$SOLR_VERSION.tgz";
+
diff --git a/solr/packaging/build.gradle b/solr/packaging/build.gradle
index 0fb8629..e79f3b2 100644
--- a/solr/packaging/build.gradle
+++ b/solr/packaging/build.gradle
@@ -42,6 +42,7 @@ configurations {
   docs
   docker
   solrTgz
+  solrTgzSignature
   solrZip
 }
 
@@ -193,6 +194,11 @@ task signDistTar(type: Sign) {
   dependsOn failUnlessGpgKeyProperty
   sign configurations.solrTgz
 }
+artifacts {
+  solrTgzSignature(signDistTar.signatureFiles.singleFile) {
+    builtBy signDistTar
+  }
+}
 task signDistZip(type: Sign) {
   dependsOn failUnlessGpgKeyProperty
   sign configurations.solrZip