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
--- 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