Bug 1324414 - Reference prebuilt docker images by HASH. r?dustin draft
authorJonas Finnemann Jensen <jopsen@gmail.com>
Mon, 19 Dec 2016 11:31:56 +0100
changeset 451029 0cd18bac33867aa93da1979ec97cb684e349d827
parent 449987 6dbc6e9f62a705d5f523cc750811bd01c8275ec6
child 539903 5b9b8f3d59f88f3e5f73fbcf17d8b0c4e4795d0b
push id39028
push userbmo:jopsen@gmail.com
push dateMon, 19 Dec 2016 13:00:30 +0000
reviewersdustin
bugs1324414
milestone53.0a1
Bug 1324414 - Reference prebuilt docker images by HASH. r?dustin This adds a HASH file next to the VERSION file in the image context folders for prebuilt docker images. And uses the HASH for referencing the image in the tasks created by the decision task. This way docker will validate the image hash when pulling it in production. Thus, attackers won't be able to inject code by compromising the remote docker registries we use to store prebuilt images. Further more, this makes validation of the Chain-Of-Trust artifacts easier as this eliminates the need for whitelists and hash validation. MozReview-Commit-ID: FD3B9MyeU9Q
taskcluster/taskgraph/docker.py
taskcluster/taskgraph/test/test_util_docker.py
taskcluster/taskgraph/util/docker.py
testing/docker/README.md
testing/docker/base-build/HASH
testing/docker/base-test/HASH
testing/docker/centos6-build-upd/HASH
testing/docker/centos6-build/HASH
testing/docker/decision/HASH
testing/docker/image_builder/HASH
testing/docker/tester/HASH
--- a/taskcluster/taskgraph/docker.py
+++ b/taskcluster/taskgraph/docker.py
@@ -15,17 +15,16 @@ import tempfile
 import urllib2
 import which
 from subprocess import Popen, PIPE
 from io import BytesIO
 
 from taskgraph.util import docker
 
 GECKO = os.path.realpath(os.path.join(__file__, '..', '..', '..'))
-IMAGE_DIR = os.path.join(GECKO, 'testing', 'docker')
 INDEX_URL = 'https://index.taskcluster.net/v1/task/' + docker.INDEX_PREFIX + '.{}.{}.hash.{}'
 ARTIFACT_URL = 'https://queue.taskcluster.net/v1/task/{}/artifacts/{}'
 
 
 def load_image_by_name(image_name, tag=None):
     context_path = os.path.join(GECKO, 'testing', 'docker', image_name)
     context_hash = docker.generate_context_hash(GECKO, context_path, image_name)
 
@@ -51,36 +50,36 @@ def load_image_by_task_id(task_id, tag=N
 def build_context(name, outputFile):
     """Build a context.tar for image with specified name.
     """
     if not name:
         raise ValueError('must provide a Docker image name')
     if not outputFile:
         raise ValueError('must provide a outputFile')
 
-    image_dir = os.path.join(IMAGE_DIR, name)
+    image_dir = os.path.join(docker.IMAGE_DIR, name)
     if not os.path.isdir(image_dir):
         raise Exception('image directory does not exist: %s' % image_dir)
 
     docker.create_context_tar(GECKO, image_dir, outputFile, "")
 
 
 def build_image(name):
     """Build a Docker image of specified name.
 
     Output from image building process will be printed to stdout.
     """
     if not name:
         raise ValueError('must provide a Docker image name')
 
-    image_dir = os.path.join(IMAGE_DIR, name)
+    image_dir = os.path.join(docker.IMAGE_DIR, name)
     if not os.path.isdir(image_dir):
         raise Exception('image directory does not exist: %s' % image_dir)
 
-    tag = docker.docker_image(name, default_version='latest')
+    tag = docker.docker_image(name, by_tag=True)
 
     docker_bin = which.which('docker')
 
     # Verify that Docker is working.
     try:
         subprocess.check_output([docker_bin, '--version'])
     except subprocess.CalledProcessError:
         raise Exception('Docker server is unresponsive. Run `docker ps` and '
--- a/taskcluster/taskgraph/test/test_util_docker.py
+++ b/taskcluster/taskgraph/test/test_util_docker.py
@@ -41,27 +41,45 @@ class TestDocker(unittest.TestCase):
                 'e61e675ce05e8c11424437db3f1004079374c1a5fe6ad6800346cebe137b0797'
             )
         finally:
             docker.GECKO = old_GECKO
             shutil.rmtree(tmpdir)
 
     def test_docker_image_explicit_registry(self):
         files = {}
-        files["{}/myimage/REGISTRY".format(docker.DOCKER_ROOT)] = "cool-images"
-        files["{}/myimage/VERSION".format(docker.DOCKER_ROOT)] = "1.2.3"
+        files["{}/myimage/REGISTRY".format(docker.IMAGE_DIR)] = "cool-images"
+        files["{}/myimage/VERSION".format(docker.IMAGE_DIR)] = "1.2.3"
+        files["{}/myimage/HASH".format(docker.IMAGE_DIR)] = "sha256:434..."
         with MockedOpen(files):
-            self.assertEqual(docker.docker_image('myimage'), "cool-images/myimage:1.2.3")
+            self.assertEqual(docker.docker_image('myimage'), "cool-images/myimage@sha256:434...")
+
+    def test_docker_image_explicit_registry_by_tag(self):
+        files = {}
+        files["{}/myimage/REGISTRY".format(docker.IMAGE_DIR)] = "myreg"
+        files["{}/myimage/VERSION".format(docker.IMAGE_DIR)] = "1.2.3"
+        files["{}/myimage/HASH".format(docker.IMAGE_DIR)] = "sha256:434..."
+        with MockedOpen(files):
+            self.assertEqual(docker.docker_image('myimage', by_tag=True), "myreg/myimage:1.2.3")
 
     def test_docker_image_default_registry(self):
         files = {}
-        files["{}/REGISTRY".format(docker.DOCKER_ROOT)] = "mozilla"
-        files["{}/myimage/VERSION".format(docker.DOCKER_ROOT)] = "1.2.3"
+        files["{}/REGISTRY".format(docker.IMAGE_DIR)] = "mozilla"
+        files["{}/myimage/VERSION".format(docker.IMAGE_DIR)] = "1.2.3"
+        files["{}/myimage/HASH".format(docker.IMAGE_DIR)] = "sha256:434..."
         with MockedOpen(files):
-            self.assertEqual(docker.docker_image('myimage'), "mozilla/myimage:1.2.3")
+            self.assertEqual(docker.docker_image('myimage'), "mozilla/myimage@sha256:434...")
+
+    def test_docker_image_default_registry_by_tag(self):
+        files = {}
+        files["{}/REGISTRY".format(docker.IMAGE_DIR)] = "mozilla"
+        files["{}/myimage/VERSION".format(docker.IMAGE_DIR)] = "1.2.3"
+        files["{}/myimage/HASH".format(docker.IMAGE_DIR)] = "sha256:434..."
+        with MockedOpen(files):
+            self.assertEqual(docker.docker_image('myimage', by_tag=True), "mozilla/myimage:1.2.3")
 
     def test_create_context_tar_basic(self):
         tmp = tempfile.mkdtemp()
         try:
             d = os.path.join(tmp, 'test_image')
             os.mkdir(d)
             with open(os.path.join(d, 'Dockerfile'), 'a'):
                 pass
--- a/taskcluster/taskgraph/util/docker.py
+++ b/taskcluster/taskgraph/util/docker.py
@@ -12,41 +12,47 @@ import tarfile
 import tempfile
 
 from mozpack.archive import (
     create_tar_gz_from_files,
 )
 
 
 GECKO = os.path.realpath(os.path.join(__file__, '..', '..', '..', '..'))
-DOCKER_ROOT = os.path.join(GECKO, 'testing', 'docker')
+IMAGE_DIR = os.path.join(GECKO, 'testing', 'docker')
 INDEX_PREFIX = 'docker.images.v2'
 ARTIFACT_URL = 'https://queue.taskcluster.net/v1/task/{}/artifacts/{}'
 
 
-def docker_image(name, default_version=None):
-    '''Determine the docker image name, including repository and tag, from an
-    in-tree docker file.'''
+def docker_image(name, by_tag=False):
+    '''
+        Resolve in-tree prebuilt docker image to ``<registry>/<repository>@sha256:<digest>``,
+        or ``<registry>/<repository>:<tag>`` if `by_tag` is `True`.
+    '''
     try:
-        with open(os.path.join(DOCKER_ROOT, name, 'REGISTRY')) as f:
+        with open(os.path.join(IMAGE_DIR, name, 'REGISTRY')) as f:
             registry = f.read().strip()
     except IOError:
-        with open(os.path.join(DOCKER_ROOT, 'REGISTRY')) as f:
+        with open(os.path.join(IMAGE_DIR, 'REGISTRY')) as f:
             registry = f.read().strip()
 
+    if not by_tag:
+        hashfile = os.path.join(IMAGE_DIR, name, 'HASH')
+        try:
+            with open(hashfile) as f:
+                return '{}/{}@{}'.format(registry, name, f.read().strip())
+        except IOError:
+            raise Exception('Failed to read HASH file {}'.format(hashfile))
+
     try:
-        with open(os.path.join(DOCKER_ROOT, name, 'VERSION')) as f:
-            version = f.read().strip()
+        with open(os.path.join(IMAGE_DIR, name, 'VERSION')) as f:
+            tag = f.read().strip()
     except IOError:
-        if not default_version:
-            raise
-
-        version = default_version
-
-    return '{}/{}:{}'.format(registry, name, version)
+        tag = 'latest'
+    return '{}/{}:{}'.format(registry, name, tag)
 
 
 def generate_context_hash(topsrcdir, image_path, image_name):
     """Generates a sha256 hash for context directory used to build an image."""
 
     # It is a bit unfortunate we have to create a temp file here - it would
     # be nicer to use an in-memory buffer.
     fd, p = tempfile.mkstemp()
--- a/testing/docker/README.md
+++ b/testing/docker/README.md
@@ -84,44 +84,57 @@ follow this pattern.***
 
 These are images that are intended to be pushed to a docker registry and used by specifying the
 folder name in task definitions.  This information is automatically populated by using the 'docker_image'
 convenience method in task definitions.
 
 Example:
   image: {#docker_image}builder{/docker_image}
 
-Each image has a version, given by its `VERSION` file.  This should be bumped when any changes are made that will be deployed into taskcluster.
-Then, older tasks which were designed to run on an older version of the image can still be executed in taskcluster, while new tasks can use the new version.
+Each image has a hash and a version, given by its `HASH` and `VERSION` files.
+When rebuilding a prebuilt image the `VERSION` should be bumped. Once a new
+version of the image has been built the `HASH` file should be updated with the
+hash of the image.
 
-Each image also has a `REGISTRY`, defaulting to the `REGISTRY` in this directory, and specifying the image registry to which the completed image should be uploaded.
+The `HASH` file is the image hash as computed by docker, this is always on the
+format `sha256:<digest>`. In production images will be referenced by image hash.
+This mitigates attacks against the registry as well as simplifying validate of
+correctness. The `VERSION` file only serves to provide convenient names, such
+that old versions are easy to discover in the registry (and ensuring old
+versions aren't deleted by garbage-collection).
+
+This way, older tasks which were designed to run on an older version of the image
+can still be executed in taskcluster, while new tasks can use the new version.
+Further more, this mitigates attacks against the registry as docker will verify
+the image hash when loading the image.
+
+Each image also has a `REGISTRY`, defaulting to the `REGISTRY` in this directory,
+and specifying the image registry to which the completed image should be uploaded.
 
 ## Building images
 
 Generally, images can be pulled from the [registry](./REGISTRY) rather than
 built locally, however, for developing new images it's often helpful to hack on
 them locally.
 
-To build an image, invoke `build.sh` with the name of the folder (without a trailing slash):
+To build an image, invoke `mach taskcluster-build-image` with the name of the
+folder (without a trailing slash):
 ```sh
-./build.sh base
+./mach taskcluster-build-image <image-name>
 ```
 
-This is a tiny wrapper around building the docker images via `docker
-build -t $REGISTRY/$FOLDER:$FOLDER_VERSION`
+This is a tiny wrapper around `docker build -t $REGISTRY/$FOLDER:$VERSION`.
+Once a new version image has been built and pushed to the remote registry using
+`docker push $REGISTRY/$FOLDER:$VERSION` the `HASH` file must be updated for the
+change to effect in production.
 
 Note: If no "VERSION" file present in the image directory, the tag 'latest' will be used and no
-registry user will be defined.  The image is only meant to run locally and will overwrite
+registry will be defined. The image is only meant to run locally and will overwrite
 any existing image with the same name and tag.
 
-On completion, if the image has been tagged with a version and registry, `build.sh` gives a
-command to upload the image to the registry, but this is not necessary until the image
-is ready for production usage. Docker will successfully find the local, tagged image
-while you continue to hack on the image definitions.
-
 ## Adding a new image
 
 The docker image primitives are very basic building block for
 constructing an "image" but generally don't help much with tagging it
 for deployment so we have a wrapper (./build.sh) which adds some sugar
 to help with tagging/versioning... Each folder should look something
 like this:
 
new file mode 100644
--- /dev/null
+++ b/testing/docker/base-build/HASH
@@ -0,0 +1,1 @@
+sha256:65b337ec35f59d77ccc81780c76d35716a9fd0b20d1910bc93bd771814ef9e25
new file mode 100644
--- /dev/null
+++ b/testing/docker/base-test/HASH
@@ -0,0 +1,1 @@
+sha256:3c26645174f3f9765f226ec3e3fa44321b7beee0ca447a47ba7ef8937b639c18
new file mode 100644
--- /dev/null
+++ b/testing/docker/centos6-build-upd/HASH
@@ -0,0 +1,1 @@
+sha256:6e9abe28cec9a768b940ecd726e4f394bc9bd9179ed78d170213352d56f72320
new file mode 100644
--- /dev/null
+++ b/testing/docker/centos6-build/HASH
@@ -0,0 +1,1 @@
+sha256:fc8388461531114d1f6e3bea71110c26bfd85030768a03b4dd2211605a16ac41
new file mode 100644
--- /dev/null
+++ b/testing/docker/decision/HASH
@@ -0,0 +1,1 @@
+sha256:9db282317340838f0015335d74ed56c4ee0dbad588be33e6999928a181548587
new file mode 100644
--- /dev/null
+++ b/testing/docker/image_builder/HASH
@@ -0,0 +1,1 @@
+sha256:13b80a7a6b8e10c6096aba5a435529fbc99b405f56012e57cc6835facf4b40fb
new file mode 100644
--- /dev/null
+++ b/testing/docker/tester/HASH
@@ -0,0 +1,1 @@
+sha256:99aaab7460be0cce3f93abfa703adad51a74755566367c906460829f3483e567